import React, { useRef, useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Map, RotateCcw, ZoomIn, ZoomOut, Maximize, Minimize, Navigation, Eye, EyeOff } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface Robot {
robot_id: string;
robot_type: string;
name?: string;
position?: { x: number; y: number; z?: number };
rotation?: { yaw: number };
connected: boolean;
status?: string;
}
interface MapData {
map_id?: string;
rooms?: Array<{
id: string;
name: string;
coordinates: number[];
type?: string;
}>;
obstacles?: Array<{
x: number;
y: number;
size?: number;
type?: string;
}>;
charging_station?: {
x: number;
y: number;
orientation?: number;
};
walls?: Array<{
start: { x: number; y: number };
end: { x: number; y: number };
}>;
// Dreame-specific fields
dreame_map?: {
map_data?: any; // Raw Dreame map data
scale_factor?: number;
origin_x?: number;
origin_y?: number;
};
lidar_points?: Array<{
x: number;
y: number;
distance: number;
angle: number;
quality?: number;
}>;
}
interface MapVisualizationProps {
robots: Robot[];
mapData?: MapData;
onRobotCommand?: (robotId: string, command: any) => void;
className?: string;
onLoadDreameMap?: (robotId: string) => Promise<void>;
}
export function MapVisualization({ robots, mapData, onRobotCommand, onLoadDreameMap, className }: MapVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [zoom, setZoom] = useState(1.0);
const [panX, setPanX] = useState(0);
const [panY, setPanY] = useState(0);
const [selectedRobot, setSelectedRobot] = useState<string | null>(null);
const [showTrails, setShowTrails] = useState(true);
const [autoCenter, setAutoCenter] = useState(true);
const [robotTrails, setRobotTrails] = useState<{ [key: string]: Array<{ x: number; y: number; timestamp: number }> }>({});
// Canvas dimensions
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
// Scale factor for map coordinates (Dreame maps are usually in mm, convert to meters)
const SCALE_FACTOR = mapData?.dreame_map?.scale_factor || 0.001;
// Check for Dreame robots
const dreameRobots = robots.filter(robot => robot.robot_type === 'dreame' && robot.connected);
// Load Dreame map function
const loadDreameMap = async (robotId: string) => {
if (onLoadDreameMap) {
await onLoadDreameMap(robotId);
}
};
// Colors
const COLORS = {
background: '#f8f9fa',
floor: '#e8f5e8',
rooms: ['#dbeafe', '#fef3c7', '#d1fae5', '#fce7f3', '#e0f2fe'],
obstacles: '#dc2626',
chargingStation: '#16a34a',
walls: '#6b7280',
robot: {
dreame: '#3b82f6', // Blue
yahboom: '#f59e0b', // Amber
hue: '#8b5cf6', // Purple
scout: '#10b981', // Green
go2: '#ef4444', // Red
default: '#6b7280' // Gray
},
trail: '#94a3b8',
grid: '#e5e7eb'
};
useEffect(() => {
drawMap();
}, [mapData, robots, zoom, panX, panY, showTrails, selectedRobot]);
useEffect(() => {
// Update robot trails
const now = Date.now();
robots.forEach(robot => {
if (robot.position) {
setRobotTrails(prev => ({
...prev,
[robot.robot_id]: [
...(prev[robot.robot_id] || []).slice(-50), // Keep last 50 positions
{ x: robot.position!.x, y: robot.position!.y, timestamp: now }
].filter(point => now - point.timestamp < 300000) // Keep last 5 minutes
}));
}
});
}, [robots]);
const drawMap = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Save context for transformations
ctx.save();
// Apply zoom and pan
ctx.translate(CANVAS_WIDTH / 2 + panX, CANVAS_HEIGHT / 2 + panY);
ctx.scale(zoom, zoom);
// Draw background
ctx.fillStyle = COLORS.background;
ctx.fillRect(-CANVAS_WIDTH / 2, -CANVAS_HEIGHT / 2, CANVAS_WIDTH, CANVAS_HEIGHT);
if (mapData) {
drawFloor(ctx);
drawRooms(ctx);
drawWalls(ctx);
drawObstacles(ctx);
drawChargingStation(ctx);
} else {
// No map data - draw empty grid
drawGrid(ctx);
}
// Draw robot trails
if (showTrails) {
drawRobotTrails(ctx);
}
// Draw robots
drawRobots(ctx);
// Restore context
ctx.restore();
// Draw UI overlays (zoom level, coordinates, etc.)
drawUIOverlays(ctx);
};
const drawFloor = (ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = COLORS.floor;
ctx.fillRect(-CANVAS_WIDTH / 2 / zoom, -CANVAS_HEIGHT / 2 / zoom, CANVAS_WIDTH / zoom, CANVAS_HEIGHT / zoom);
};
const drawGrid = (ctx: CanvasRenderingContext2D) => {
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1 / zoom;
const gridSize = 1000; // 1m grid
const startX = Math.floor((-CANVAS_WIDTH / 2 / zoom - panX / zoom) / gridSize) * gridSize;
const endX = Math.ceil((CANVAS_WIDTH / 2 / zoom - panX / zoom) / gridSize) * gridSize;
const startY = Math.floor((-CANVAS_HEIGHT / 2 / zoom - panY / zoom) / gridSize) * gridSize;
const endY = Math.ceil((CANVAS_HEIGHT / 2 / zoom - panY / zoom) / gridSize) * gridSize;
for (let x = startX; x <= endX; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
ctx.stroke();
}
for (let y = startY; y <= endY; y += gridSize) {
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
};
const drawRooms = (ctx: CanvasRenderingContext2D) => {
if (!mapData?.rooms) return;
mapData.rooms.forEach((room, index) => {
if (!room.coordinates || room.coordinates.length < 6) return;
ctx.fillStyle = COLORS.rooms[index % COLORS.rooms.length];
ctx.strokeStyle = COLORS.walls;
ctx.lineWidth = 20 / zoom; // 2cm walls
// Draw room polygon
ctx.beginPath();
for (let i = 0; i < room.coordinates.length; i += 2) {
const x = room.coordinates[i] * SCALE_FACTOR;
const y = room.coordinates[i + 1] * SCALE_FACTOR;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// Draw room label
if (room.name) {
const centerX = room.coordinates.reduce((sum, val, idx) => idx % 2 === 0 ? sum + val : sum, 0) / (room.coordinates.length / 2) * SCALE_FACTOR;
const centerY = room.coordinates.reduce((sum, val, idx) => idx % 2 === 1 ? sum + val : sum, 0) / (room.coordinates.length / 2) * SCALE_FACTOR;
ctx.fillStyle = '#000';
ctx.font = `${24 / zoom}px Arial`;
ctx.textAlign = 'center';
ctx.fillText(room.name, centerX, centerY);
}
});
};
const drawWalls = (ctx: CanvasRenderingContext2D) => {
if (!mapData?.walls) return;
ctx.strokeStyle = COLORS.walls;
ctx.lineWidth = 20 / zoom; // 2cm walls
mapData.walls.forEach(wall => {
ctx.beginPath();
ctx.moveTo(wall.start.x * SCALE_FACTOR, wall.start.y * SCALE_FACTOR);
ctx.lineTo(wall.end.x * SCALE_FACTOR, wall.end.y * SCALE_FACTOR);
ctx.stroke();
});
};
const drawObstacles = (ctx: CanvasRenderingContext2D) => {
if (!mapData?.obstacles) return;
ctx.fillStyle = COLORS.obstacles;
mapData.obstacles.forEach(obstacle => {
const x = obstacle.x * SCALE_FACTOR;
const y = obstacle.y * SCALE_FACTOR;
const size = (obstacle.size || 50) * SCALE_FACTOR; // Default 5cm
ctx.beginPath();
ctx.arc(x, y, size, 0, 2 * Math.PI);
ctx.fill();
});
};
const drawChargingStation = (ctx: CanvasRenderingContext2D) => {
if (!mapData?.charging_station) return;
const station = mapData.charging_station;
const x = station.x * SCALE_FACTOR;
const y = station.y * SCALE_FACTOR;
ctx.fillStyle = COLORS.chargingStation;
ctx.strokeStyle = '#166534';
ctx.lineWidth = 5 / zoom;
// Draw charging station as a rectangle
const width = 300 * SCALE_FACTOR; // 30cm
const height = 200 * SCALE_FACTOR; // 20cm
ctx.fillRect(x - width / 2, y - height / 2, width, height);
ctx.strokeRect(x - width / 2, y - height / 2, width, height);
// Draw charging symbol
ctx.fillStyle = '#fff';
ctx.font = `${48 / zoom}px Arial`;
ctx.textAlign = 'center';
ctx.fillText('⚡', x, y + 8 / zoom);
};
const drawRobotTrails = (ctx: CanvasRenderingContext2D) => {
Object.entries(robotTrails).forEach(([robotId, trail]) => {
if (trail.length < 2) return;
const robot = robots.find(r => r.robot_id === robotId);
const color = robot ? COLORS.robot[robot.robot_type as keyof typeof COLORS.robot] || COLORS.robot.default : COLORS.robot.default;
ctx.strokeStyle = color;
ctx.lineWidth = 8 / zoom;
ctx.globalAlpha = 0.6;
ctx.beginPath();
trail.forEach((point, index) => {
const x = point.x;
const y = point.y;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
ctx.globalAlpha = 1;
});
};
const drawRobots = (ctx: CanvasRenderingContext2D) => {
robots.forEach(robot => {
if (!robot.position) return;
const x = robot.position.x;
const y = robot.position.y;
const color = COLORS.robot[robot.robot_type as keyof typeof COLORS.robot] || COLORS.robot.default;
// Draw robot body (circle)
ctx.fillStyle = robot.connected ? color : '#6b7280';
ctx.strokeStyle = selectedRobot === robot.robot_id ? '#000' : '#fff';
ctx.lineWidth = 3 / zoom;
ctx.beginPath();
ctx.arc(x, y, 150 * SCALE_FACTOR, 0, 2 * Math.PI); // 15cm radius
ctx.fill();
ctx.stroke();
// Draw orientation arrow
if (robot.rotation?.yaw !== undefined) {
const angle = robot.rotation.yaw;
const arrowLength = 200 * SCALE_FACTOR; // 20cm arrow
ctx.strokeStyle = '#000';
ctx.lineWidth = 5 / zoom;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(
x + Math.cos(angle) * arrowLength,
y + Math.sin(angle) * arrowLength
);
ctx.stroke();
// Arrow head
const headLength = 50 * SCALE_FACTOR;
ctx.beginPath();
ctx.moveTo(
x + Math.cos(angle) * arrowLength,
y + Math.sin(angle) * arrowLength
);
ctx.lineTo(
x + Math.cos(angle) * (arrowLength - headLength) + Math.sin(angle) * headLength,
y + Math.sin(angle) * (arrowLength - headLength) - Math.cos(angle) * headLength
);
ctx.moveTo(
x + Math.cos(angle) * arrowLength,
y + Math.sin(angle) * arrowLength
);
ctx.lineTo(
x + Math.cos(angle) * (arrowLength - headLength) - Math.sin(angle) * headLength,
y + Math.sin(angle) * (arrowLength - headLength) + Math.cos(angle) * headLength
);
ctx.stroke();
}
// Draw robot label
ctx.fillStyle = '#000';
ctx.font = `${20 / zoom}px Arial`;
ctx.textAlign = 'center';
ctx.fillText(robot.name || robot.robot_id, x, y - 200 * SCALE_FACTOR);
// Draw status indicator
const statusColor = robot.connected ? '#10b981' : '#ef4444';
ctx.fillStyle = statusColor;
ctx.beginPath();
ctx.arc(x + 100 * SCALE_FACTOR, y - 100 * SCALE_FACTOR, 15 * SCALE_FACTOR, 0, 2 * Math.PI);
ctx.fill();
});
};
const drawUIOverlays = (ctx: CanvasRenderingContext2D) => {
// Reset transformations for UI elements
ctx.setTransform(1, 0, 0, 1, 0, 0);
// Draw zoom level
ctx.fillStyle = '#000';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillText(`Zoom: ${(zoom * 100).toFixed(0)}%`, 10, 20);
// Draw coordinates of mouse or center
ctx.fillText(`Center: (${(panX / zoom).toFixed(2)}, ${(panY / zoom).toFixed(2)})`, 10, 40);
// Draw legend
const legendX = CANVAS_WIDTH - 150;
let legendY = 20;
ctx.font = '12px Arial';
ctx.textAlign = 'left';
// Rooms
ctx.fillStyle = COLORS.rooms[0];
ctx.fillRect(legendX, legendY, 12, 12);
ctx.fillStyle = '#000';
ctx.fillText('Rooms', legendX + 18, legendY + 10);
legendY += 18;
// Obstacles
ctx.fillStyle = COLORS.obstacles;
ctx.fillRect(legendX, legendY, 12, 12);
ctx.fillStyle = '#000';
ctx.fillText('Obstacles', legendX + 18, legendY + 10);
legendY += 18;
// Charging Station
ctx.fillStyle = COLORS.chargingStation;
ctx.fillRect(legendX, legendY, 12, 12);
ctx.fillStyle = '#000';
ctx.fillText('Charger', legendX + 18, legendY + 10);
legendY += 18;
// Robots
Object.entries(COLORS.robot).forEach(([type, color]) => {
ctx.fillStyle = color;
ctx.fillRect(legendX, legendY, 12, 12);
ctx.fillStyle = '#000';
ctx.fillText(type.charAt(0).toUpperCase() + type.slice(1), legendX + 18, legendY + 10);
legendY += 18;
});
};
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left - CANVAS_WIDTH / 2 - panX;
const y = event.clientY - rect.top - CANVAS_HEIGHT / 2 - panY;
// Convert screen coordinates to world coordinates
const worldX = x / zoom;
const worldY = y / zoom;
// Check if clicking on a robot
const clickedRobot = robots.find(robot => {
if (!robot.position) return false;
const dx = robot.position.x - worldX;
const dy = robot.position.y - worldY;
return Math.sqrt(dx * dx + dy * dy) < 150 * SCALE_FACTOR;
});
if (clickedRobot) {
setSelectedRobot(clickedRobot.robot_id);
if (onRobotCommand) {
onRobotCommand(clickedRobot.robot_id, { action: 'get_status' });
}
} else {
setSelectedRobot(null);
}
};
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.2, 5));
const handleZoomOut = () => setZoom(prev => Math.max(prev / 1.2, 0.1));
const handleResetView = () => {
setZoom(1.0);
setPanX(0);
setPanY(0);
};
const handleCenterOnRobot = (robotId: string) => {
const robot = robots.find(r => r.robot_id === robotId);
if (robot?.position) {
setPanX(-robot.position.x);
setPanY(-robot.position.y);
setSelectedRobot(robotId);
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Map className="w-5 h-5 text-blue-500" />
Multi-Robot Map Visualization
</CardTitle>
<CardDescription>
Real-time LIDAR maps with robot positions and navigation
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Map Controls */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleZoomIn}
disabled={zoom >= 5}
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={handleZoomOut}
disabled={zoom <= 0.1}
>
<ZoomOut className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={handleResetView}
>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Zoom:</label>
<Slider
value={[zoom]}
onValueChange={([value]) => setZoom(value)}
min={0.1}
max={5}
step={0.1}
className="w-20"
/>
<span className="text-sm text-gray-600">{(zoom * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={showTrails ? "default" : "outline"}
onClick={() => setShowTrails(!showTrails)}
>
{showTrails ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
Trails
</Button>
</div>
</div>
{/* Robot Quick Select */}
{robots.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">Center on:</span>
{robots.map(robot => (
<Button
key={robot.robot_id}
size="sm"
variant={selectedRobot === robot.robot_id ? "default" : "outline"}
onClick={() => handleCenterOnRobot(robot.robot_id)}
disabled={!robot.position}
>
<Navigation className="w-4 h-4 mr-1" />
{robot.name || robot.robot_id}
</Button>
))}
</div>
)}
{/* Map Canvas */}
<div className="border rounded-lg overflow-hidden">
<canvas
ref={canvasRef}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
className="cursor-crosshair bg-gray-50"
onClick={handleCanvasClick}
style={{ maxWidth: '100%', height: 'auto' }}
/>
</div>
{/* Dreame Map Loading */}
{dreameRobots.length > 0 && !mapData && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-lg font-medium mb-2 text-blue-900">🗺️ Load Dreame LIDAR Map</h3>
<p className="text-sm text-blue-700 mb-3">
Load real-time LIDAR mapping data from your Dreame vacuum robot for precise navigation and obstacle detection.
</p>
<div className="flex gap-2">
{dreameRobots.map(robot => (
<Button
key={robot.robot_id}
onClick={() => loadDreameMap(robot.robot_id)}
className="bg-blue-600 hover:bg-blue-700 text-white"
size="sm"
>
Load Map from {robot.name || robot.robot_id}
</Button>
))}
</div>
</div>
)}
{/* Map Info */}
{mapData && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="font-medium">Map ID:</span>
<span className="ml-2">{mapData.map_id || 'Unknown'}</span>
</div>
<div>
<span className="font-medium">Rooms:</span>
<span className="ml-2">{mapData.rooms?.length || 0}</span>
</div>
<div>
<span className="font-medium">Obstacles:</span>
<span className="ml-2">{mapData.obstacles?.length || 0}</span>
</div>
<div>
<span className="font-medium">Robots:</span>
<span className="ml-2">{robots.filter(r => r.position).length}</span>
</div>
</div>
)}
{/* Status Messages */}
{!mapData && (
<Alert>
<Map className="h-4 w-4" />
<AlertDescription>
No LIDAR map data available. Connect a Dreame robot and start mapping to see the map.
</AlertDescription>
</Alert>
)}
{robots.filter(r => r.position).length === 0 && (
<Alert>
<Navigation className="h-4 w-4" />
<AlertDescription>
No robot positions available. Robots need to be connected and reporting position data.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}