diff --git a/client/src/App.jsx b/client/src/App.jsx
index 70d6d9e..9b76aa5 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -6,13 +6,14 @@ import StatsPanel from './components/StatsPanel';
import GroupsPanel from './components/GroupsPanel';
import TaskPanel from './components/TaskPanel';
import PathRecorder from './components/PathRecorder';
+import MiningAreasPanel from './components/MiningAreasPanel';
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', 'paths'
+ const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
const turtles = useTurtleStore((state) => state.getTurtleArray());
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
@@ -37,6 +38,8 @@ function App() {
return ;
case 'paths':
return ;
+ case 'areas':
+ return ;
default:
return ;
}
@@ -116,6 +119,13 @@ function App() {
>
🛤️ Paths
+
{renderPanelContent()}
diff --git a/client/src/components/Map3D.jsx b/client/src/components/Map3D.jsx
index 675cc46..2753860 100644
--- a/client/src/components/Map3D.jsx
+++ b/client/src/components/Map3D.jsx
@@ -414,12 +414,127 @@ function HomeMarker({ position }) {
);
}
+// Mining area wireframe component
+function MiningArea({ area, turtle, isSelected, onClick }) {
+ const meshRef = useRef();
+ const [hovered, setHovered] = useState(false);
+
+ // Calculate dimensions
+ const width = Math.abs(area.endX - area.startX) + 1;
+ const height = Math.abs(area.endY - area.startY) + 1;
+ const depth = Math.abs(area.endZ - area.startZ) + 1;
+
+ // Calculate center position
+ const centerX = (area.startX + area.endX) / 2;
+ const centerY = (area.startY + area.endY) / 2;
+ const centerZ = (area.startZ + area.endZ) / 2;
+
+ // Color based on turtle or status
+ const getColor = () => {
+ if (area.status === 'completed') return '#10b981'; // green
+ if (area.status === 'mining') return '#f59e0b'; // orange
+ if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
+ return '#6366f1'; // default purple
+ };
+
+ const color = getColor();
+ const opacity = isSelected ? 0.3 : hovered ? 0.2 : 0.1;
+ const lineWidth = isSelected ? 2.5 : hovered ? 2 : 1.5;
+
+ // Animate rotation slightly
+ useFrame((state) => {
+ if (meshRef.current && isSelected) {
+ meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
+ }
+ });
+
+ return (
+
setHovered(true)}
+ onPointerOut={() => setHovered(false)}
+ ref={meshRef}
+ >
+ {/* Wireframe box */}
+
+
+
+
+
+ {/* Semi-transparent fill */}
+
+
+
+
+
+ {/* Label */}
+
+ {area.areaName || `Area ${area.areaID}`}
+
+
+ {/* Dimensions label */}
+
+ {width}×{height}×{depth}
+
+
+ {/* Status indicator */}
+ {area.status === 'mining' && (
+
+
+
+
+ )}
+
+ );
+}
+
// Main scene component
function Scene() {
const turtles = useTurtleStore((state) => state.getTurtleArray());
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
+
+ // Mining areas state
+ const [miningAreas, setMiningAreas] = useState([]);
+ const [selectedAreaId, setSelectedAreaId] = useState(null);
+
+ // Load mining areas
+ useEffect(() => {
+ const fetchMiningAreas = async () => {
+ try {
+ const response = await fetch('http://localhost:3001/api/mining-areas');
+ if (response.ok) {
+ const data = await response.json();
+ setMiningAreas(data);
+ }
+ } catch (error) {
+ console.error('Failed to fetch mining areas:', error);
+ }
+ };
+
+ fetchMiningAreas();
+ const interval = setInterval(fetchMiningAreas, 5000); // Refresh every 5 seconds
+ return () => clearInterval(interval);
+ }, []);
// Calculate center point for camera focus
const centerPoint = useMemo(() => {
@@ -469,6 +584,20 @@ function Scene() {
{/* Render discovered blocks */}
+ {/* Mining areas */}
+ {miningAreas.map((area) => {
+ const turtle = turtles.find(t => t.turtleID === area.turtleID);
+ return (
+
setSelectedAreaId(selectedAreaId === area.areaID ? null : area.areaID)}
+ />
+ );
+ })}
+
{/* Home marker */}
{homePosition && }
diff --git a/client/src/components/MiningAreasPanel.css b/client/src/components/MiningAreasPanel.css
new file mode 100644
index 0000000..434164f
--- /dev/null
+++ b/client/src/components/MiningAreasPanel.css
@@ -0,0 +1,434 @@
+.mining-areas-panel {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: #0f172a;
+ color: #e2e8f0;
+ overflow: hidden;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem 1.5rem;
+ background: #1e293b;
+ border-bottom: 2px solid #334155;
+}
+
+.panel-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ color: #f8fafc;
+}
+
+.btn-create {
+ padding: 0.5rem 1rem;
+ background: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-create:hover {
+ background: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+}
+
+/* Filter buttons */
+.filter-buttons {
+ display: flex;
+ gap: 0.5rem;
+ padding: 1rem 1.5rem;
+ background: #1e293b;
+ border-bottom: 1px solid #334155;
+ flex-wrap: wrap;
+}
+
+.filter-btn {
+ padding: 0.5rem 1rem;
+ background: #334155;
+ color: #94a3b8;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.filter-btn:hover {
+ background: #475569;
+ color: #e2e8f0;
+}
+
+.filter-btn.active {
+ background: #3b82f6;
+ color: white;
+}
+
+/* Create form */
+.create-form {
+ padding: 1.5rem;
+ background: #1e293b;
+ border-bottom: 1px solid #334155;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.create-form h3 {
+ margin: 0 0 1rem 0;
+ color: #f8fafc;
+}
+
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #cbd5e1;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 0.625rem;
+ background: #0f172a;
+ border: 1px solid #334155;
+ border-radius: 6px;
+ color: #e2e8f0;
+ font-size: 0.875rem;
+ transition: all 0.2s;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.coordinates-section {
+ margin-bottom: 1rem;
+}
+
+.coordinates-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.coordinates-header label {
+ color: #cbd5e1;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.btn-use-position {
+ padding: 0.375rem 0.75rem;
+ background: #10b981;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-use-position:hover:not(:disabled) {
+ background: #059669;
+}
+
+.btn-use-position:disabled {
+ background: #334155;
+ color: #64748b;
+ cursor: not-allowed;
+}
+
+.coordinate-inputs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5rem;
+}
+
+.coordinate-inputs input {
+ width: 100%;
+}
+
+.btn-submit {
+ width: 100%;
+ padding: 0.75rem;
+ background: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ margin-top: 1rem;
+}
+
+.btn-submit:hover {
+ background: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+}
+
+/* Areas list */
+.areas-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #64748b;
+}
+
+.empty-state p {
+ margin: 0.5rem 0;
+}
+
+/* Area card */
+.area-card {
+ background: #1e293b;
+ border: 1px solid #334155;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ transition: all 0.2s;
+}
+
+.area-card:hover {
+ border-color: #475569;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+.area-card.has-conflict {
+ border-color: #ef4444;
+ background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%);
+}
+
+.area-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid #334155;
+}
+
+.area-header h3 {
+ margin: 0;
+ color: #f8fafc;
+ font-size: 1.125rem;
+}
+
+.status-badge {
+ padding: 0.25rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: white;
+}
+
+/* Area info */
+.area-info {
+ margin-bottom: 1rem;
+}
+
+.info-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #334155;
+}
+
+.info-row:last-child {
+ border-bottom: none;
+}
+
+.info-label {
+ color: #94a3b8;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.info-value {
+ color: #e2e8f0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-align: right;
+}
+
+.coordinate-value {
+ font-family: 'Courier New', monospace;
+ font-size: 0.75rem;
+ word-break: break-all;
+}
+
+/* Conflict warning */
+.conflict-warning {
+ background: #7f1d1d;
+ border: 1px solid #ef4444;
+ border-radius: 6px;
+ padding: 0.75rem;
+ margin-bottom: 1rem;
+ color: #fca5a5;
+ font-size: 0.875rem;
+}
+
+.conflict-warning ul {
+ margin: 0.5rem 0 0 0;
+ padding-left: 1.5rem;
+}
+
+.conflict-warning li {
+ margin: 0.25rem 0;
+}
+
+/* Area actions */
+.area-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.btn-action {
+ flex: 1;
+ min-width: 120px;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.btn-start {
+ background: #10b981;
+ color: white;
+}
+
+.btn-start:hover {
+ background: #059669;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
+}
+
+.btn-complete {
+ background: #3b82f6;
+ color: white;
+}
+
+.btn-complete:hover {
+ background: #2563eb;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+}
+
+.btn-delete {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-delete:hover {
+ background: #dc2626;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
+}
+
+/* Scrollbar styling */
+.areas-list::-webkit-scrollbar,
+.create-form::-webkit-scrollbar {
+ width: 8px;
+}
+
+.areas-list::-webkit-scrollbar-track,
+.create-form::-webkit-scrollbar-track {
+ background: #0f172a;
+}
+
+.areas-list::-webkit-scrollbar-thumb,
+.create-form::-webkit-scrollbar-thumb {
+ background: #334155;
+ border-radius: 4px;
+}
+
+.areas-list::-webkit-scrollbar-thumb:hover,
+.create-form::-webkit-scrollbar-thumb:hover {
+ background: #475569;
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ .panel-header {
+ padding: 0.75rem 1rem;
+ }
+
+ .panel-header h2 {
+ font-size: 1.25rem;
+ }
+
+ .filter-buttons {
+ padding: 0.75rem 1rem;
+ }
+
+ .create-form {
+ padding: 1rem;
+ }
+
+ .areas-list {
+ padding: 0.75rem;
+ }
+
+ .area-actions {
+ flex-direction: column;
+ }
+
+ .btn-action {
+ width: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .coordinate-inputs {
+ grid-template-columns: 1fr;
+ }
+
+ .area-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .info-row {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+ }
+
+ .info-value {
+ text-align: left;
+ }
+}
diff --git a/client/src/components/MiningAreasPanel.jsx b/client/src/components/MiningAreasPanel.jsx
new file mode 100644
index 0000000..8687555
--- /dev/null
+++ b/client/src/components/MiningAreasPanel.jsx
@@ -0,0 +1,443 @@
+import React, { useState, useEffect } from 'react';
+import './MiningAreasPanel.css';
+
+export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
+ const [areas, setAreas] = useState([]);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [newArea, setNewArea] = useState({
+ areaName: '',
+ startX: '',
+ startY: '',
+ startZ: '',
+ endX: '',
+ endY: '',
+ endZ: '',
+ turtleID: ''
+ });
+ const [filterStatus, setFilterStatus] = useState('all'); // all, planned, mining, completed
+
+ // Load mining areas
+ useEffect(() => {
+ loadAreas();
+ const interval = setInterval(loadAreas, 5000);
+ return () => clearInterval(interval);
+ }, [apiUrl]);
+
+ const loadAreas = async () => {
+ try {
+ const response = await fetch(`${apiUrl}/api/mining-areas`);
+ if (response.ok) {
+ const data = await response.json();
+ setAreas(data);
+ }
+ } catch (error) {
+ console.error('Failed to load mining areas:', error);
+ }
+ };
+
+ // Create new mining area
+ const handleCreateArea = async (e) => {
+ e.preventDefault();
+
+ if (!newArea.areaName || !newArea.turtleID) {
+ alert('Please provide area name and assign a turtle');
+ return;
+ }
+
+ // Validate coordinates
+ const coords = ['startX', 'startY', 'startZ', 'endX', 'endY', 'endZ'];
+ for (const coord of coords) {
+ if (newArea[coord] === '' || isNaN(Number(newArea[coord]))) {
+ alert(`Please provide valid ${coord} coordinate`);
+ return;
+ }
+ }
+
+ try {
+ const response = await fetch(`${apiUrl}/api/mining-areas`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...newArea,
+ startX: Number(newArea.startX),
+ startY: Number(newArea.startY),
+ startZ: Number(newArea.startZ),
+ endX: Number(newArea.endX),
+ endY: Number(newArea.endY),
+ endZ: Number(newArea.endZ),
+ status: 'planned'
+ })
+ });
+
+ if (response.ok) {
+ setShowCreateForm(false);
+ setNewArea({
+ areaName: '',
+ startX: '',
+ startY: '',
+ startZ: '',
+ endX: '',
+ endY: '',
+ endZ: '',
+ turtleID: ''
+ });
+ loadAreas();
+ } else {
+ const error = await response.json();
+ alert(`Failed to create area: ${error.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Failed to create mining area:', error);
+ alert('Failed to create mining area');
+ }
+ };
+
+ // Delete mining area
+ const handleDeleteArea = async (areaID) => {
+ if (!confirm('Are you sure you want to delete this mining area?')) return;
+
+ try {
+ const response = await fetch(`${apiUrl}/api/mining-areas/${areaID}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ loadAreas();
+ } else {
+ alert('Failed to delete area');
+ }
+ } catch (error) {
+ console.error('Failed to delete area:', error);
+ alert('Failed to delete area');
+ }
+ };
+
+ // Update area status
+ const handleUpdateStatus = async (areaID, newStatus) => {
+ try {
+ const response = await fetch(`${apiUrl}/api/mining-areas/${areaID}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: newStatus })
+ });
+
+ if (response.ok) {
+ loadAreas();
+ } else {
+ alert('Failed to update status');
+ }
+ } catch (error) {
+ console.error('Failed to update status:', error);
+ alert('Failed to update status');
+ }
+ };
+
+ // Auto-fill from selected turtle position
+ const handleUseCurrentPosition = () => {
+ if (!selectedTurtle || !selectedTurtle.position) {
+ alert('No turtle selected or turtle has no position');
+ return;
+ }
+
+ const pos = selectedTurtle.position;
+ setNewArea(prev => ({
+ ...prev,
+ startX: pos.x.toString(),
+ startY: pos.y.toString(),
+ startZ: pos.z.toString(),
+ turtleID: selectedTurtle.turtleID.toString()
+ }));
+ };
+
+ // Calculate volume
+ const calculateVolume = (area) => {
+ const width = Math.abs(area.endX - area.startX) + 1;
+ const height = Math.abs(area.endY - area.startY) + 1;
+ const depth = Math.abs(area.endZ - area.startZ) + 1;
+ return width * height * depth;
+ };
+
+ // Check for overlapping areas
+ const checkOverlap = (area1, area2) => {
+ const overlapX = Math.max(
+ Math.min(area1.endX, area2.endX) - Math.max(area1.startX, area2.startX) + 1,
+ 0
+ );
+ const overlapY = Math.max(
+ Math.min(area1.endY, area2.endY) - Math.max(area1.startY, area2.startY) + 1,
+ 0
+ );
+ const overlapZ = Math.max(
+ Math.min(area1.endZ, area2.endZ) - Math.max(area1.startZ, area2.startZ) + 1,
+ 0
+ );
+ return overlapX > 0 && overlapY > 0 && overlapZ > 0;
+ };
+
+ // Find conflicts
+ const findConflicts = (area) => {
+ return areas.filter(other =>
+ other.areaID !== area.areaID &&
+ checkOverlap(area, other)
+ );
+ };
+
+ // Filter areas
+ const filteredAreas = areas.filter(area => {
+ if (filterStatus === 'all') return true;
+ return area.status === filterStatus;
+ });
+
+ // Status badge component
+ const StatusBadge = ({ status }) => {
+ const colors = {
+ planned: '#6366f1',
+ mining: '#f59e0b',
+ completed: '#10b981'
+ };
+ return (
+
+ {status}
+
+ );
+ };
+
+ return (
+
+
+
⛏️ Mining Areas
+
+
+
+ {/* Filter buttons */}
+
+
+
+
+
+
+
+ {/* Create form */}
+ {showCreateForm && (
+
+ )}
+
+ {/* Areas list */}
+
+ {filteredAreas.length === 0 ? (
+
+
No mining areas {filterStatus !== 'all' ? `with status "${filterStatus}"` : ''}
+ {filterStatus === 'all' && (
+
Create a new area to get started
+ )}
+
+ ) : (
+ filteredAreas.map(area => {
+ const turtle = turtles.find(t => t.turtleID === area.turtleID);
+ const conflicts = findConflicts(area);
+ const volume = calculateVolume(area);
+
+ return (
+
0 ? 'has-conflict' : ''}`}>
+
+
{area.areaName}
+
+
+
+
+
+ Turtle:
+
+ 🐢 {turtle ? `${turtle.turtleID} ${turtle.label || ''}` : area.turtleID}
+
+
+
+
+ Coordinates:
+
+ ({area.startX}, {area.startY}, {area.startZ}) →
+ ({area.endX}, {area.endY}, {area.endZ})
+
+
+
+
+ Volume:
+ {volume.toLocaleString()} blocks
+
+
+ {area.createdAt && (
+
+ Created:
+
+ {new Date(area.createdAt).toLocaleDateString()}
+
+
+ )}
+
+
+ {conflicts.length > 0 && (
+
+ ⚠️ Overlaps with {conflicts.length} other area(s):
+
+ {conflicts.map(c => (
+ - {c.areaName}
+ ))}
+
+
+ )}
+
+
+ {area.status === 'planned' && (
+
+ )}
+ {area.status === 'mining' && (
+
+ )}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+}