feat: add 3d data visualization modes for plant health and environment
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-17 22:45:24 -08:00
parent 36f3cbab5e
commit 9f4c8d88aa

View file

@ -3,11 +3,14 @@ import { Canvas, useFrame } from '@react-three/fiber';
import { Text, Instances, Instance, Html, CameraControls } from '@react-three/drei';
import * as THREE from 'three';
import { layoutApi, Floor3DData } from '../lib/layoutApi';
import { Loader2, ArrowLeft, Maximize, MousePointer2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf } from 'lucide-react';
import { Link, useSearchParams } from 'react-router-dom';
// --- Visualization Types & Config ---
type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
// Colors
const COLORS = {
// Standard Modes
VEG: '#4ade80', // green-400
FLOWER: '#a855f7', // purple-500
DRY: '#f59e0b', // amber-500
@ -15,6 +18,41 @@ const COLORS = {
EMPTY_SLOT: '#374151', // gray-700
ROOM_FLOOR: '#1f2937', // gray-800
ROOM_WALL: '#374151', // gray-700
// Health Mode
HEALTH_GOOD: '#22c55e',
HEALTH_WARN: '#eab308',
HEALTH_CRIT: '#ef4444',
// Environment Heatmaps (Low -> High)
TEMP_LOW: '#3b82f6', // Cold (Blue)
TEMP_OPTIMAL: '#22c55e', // Good (Green)
TEMP_HIGH: '#ef4444', // Hot (Red)
HUMIDITY_DRY: '#f59e0b', // Dry (Amber)
HUMIDITY_OPTIMAL: '#0ea5e9', // Good (Sky Blue)
HUMIDITY_WET: '#3b82f6', // Wet (Dark Blue)
};
// --- Mock Data Generators ---
const getMockRoomEnv = (roomName: string) => {
const hash = roomName.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
const temp = 68 + (hash % 15); // 68-83 deg F
const humidity = 40 + (hash % 40); // 40-80% RH
return { temp, humidity };
};
const getMockPlantHealth = (plantId: string) => {
const r = Math.random(); // Deterministic enough for this demo if we seed it, but random is okay for flashiness
if (r > 0.95) return 'CRITICAL';
if (r > 0.85) return 'WARNING';
return 'GOOD';
};
const lerpColor = (c1: string, c2: string, t: number) => {
const c1c = new THREE.Color(c1);
const c2c = new THREE.Color(c2);
return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString();
};
// Define State interface
@ -49,24 +87,22 @@ class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryStat
}
}
function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlantClick: (p: any) => void }) {
function PlantInstances({ positions, onPlantClick, visMode }: {
positions: any[],
onPlantClick: (p: any) => void,
visMode: VisMode
}) {
if (!positions || !Array.isArray(positions) || positions.length === 0) return null;
// Safety filter
const safePositions = positions.filter(p => p && typeof p.x === 'number' && typeof p.y === 'number' && typeof p.z === 'number');
const plants = safePositions.filter(p => p.plant);
const emptySlots = safePositions.filter(p => !p.plant);
// Limit instances to prevent crash on huge datasets just in case
if (safePositions.length > 5000) {
console.warn('Too many positions to render:', safePositions.length);
return null; // Or return a subset
}
if (safePositions.length > 5000) return null;
return (
<group>
{/* Active Plants - Render as Cylinders (Stalks/Canopy) for visibility */}
{/* Active Plants */}
{plants.length > 0 && (
<Instances
range={plants.length}
@ -78,24 +114,43 @@ function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlant
}
}}
>
{/* Make plants much taller and visible: Cylinder radius 0.2, height 0.8 */}
<cylinderGeometry args={[0.2, 0.1, 0.8, 8]} />
<meshStandardMaterial />
{plants.map((pos, i) => (
<Instance
key={pos.id || i}
// Lift y by 0.4 (half height) so it sits ON the shelf
position={[pos.x, pos.y + 0.4, pos.z]}
color={pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER :
pos.plant?.stage === 'DRYING' ? COLORS.DRY :
pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG}
/>
))}
{plants.map((pos, i) => {
let color = COLORS.VEG;
switch (visMode) {
case 'STANDARD':
color = pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER :
pos.plant?.stage === 'DRYING' ? COLORS.DRY :
pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG;
break;
case 'HEALTH':
const status = getMockPlantHealth(pos.plant.id);
color = status === 'CRITICAL' ? COLORS.HEALTH_CRIT :
status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD;
break;
case 'YIELD':
color = lerpColor('#86efac', '#14532d', Math.random());
break;
default:
color = '#555';
break;
}
return (
<Instance
key={pos.id || i}
position={[pos.x, pos.y + 0.4, pos.z]}
color={color}
/>
);
})}
</Instances>
)}
{/* Empty Slots - Keep subtle but visible */}
{emptySlots.length > 0 && (
{/* Empty Slots */}
{emptySlots.length > 0 && visMode === 'STANDARD' && (
<Instances range={emptySlots.length}>
<cylinderGeometry args={[0.05, 0.05, 0.1, 8]} />
<meshStandardMaterial color={COLORS.EMPTY_SLOT} opacity={0.3} transparent />
@ -111,142 +166,133 @@ function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlant
);
}
function FacilityScene({ data, onSelectPlant, targetView, setControls }: {
function FacilityScene({ data, onSelectPlant, targetView, setControls, visMode }: {
data: Floor3DData,
onSelectPlant: (p: any) => void,
targetView: { x: number, y: number, z: number, zoom?: boolean } | null,
setControls: (c: CameraControls | null) => void
setControls: (c: CameraControls | null) => void,
visMode: VisMode
}) {
const controlsRef = useRef<CameraControls>(null);
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
// Register controls
useEffect(() => {
if (controlsRef.current) {
setControls(controlsRef.current);
}
if (controlsRef.current) setControls(controlsRef.current);
}, [controlsRef, setControls]);
// Handle View Navigation
useEffect(() => {
if (targetView && controlsRef.current) {
const { x, y, z, zoom } = targetView;
// Smoothly move camera to look at the target room
// Position camera slightly offset from the target for a good view
const dist = zoom ? 15 : 40;
const height = zoom ? 15 : 40;
controlsRef.current.setLookAt(
x + dist, height, z + dist, // Camera Position
x, 0, z, // Target Position
true // Enable transition
);
controlsRef.current.setLookAt(x + dist, height, z + dist, x, 0, z, true);
}
}, [targetView]);
// Keyboard Controls Logic
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: true }));
const handleKeyUp = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: false }));
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
useFrame((_, delta) => {
if (!controlsRef.current) return;
const speed = 20 * delta; // Movement speed
// WASD / Arrow Keys Navigation
if (keysPressed['KeyW'] || keysPressed['ArrowUp']) {
controlsRef.current.forward(speed, true);
}
if (keysPressed['KeyS'] || keysPressed['ArrowDown']) {
controlsRef.current.forward(-speed, true);
}
if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) {
controlsRef.current.truck(-speed, 0, true);
}
if (keysPressed['KeyD'] || keysPressed['ArrowRight']) {
controlsRef.current.truck(speed, 0, true);
}
const speed = 20 * delta;
if (keysPressed['KeyW'] || keysPressed['ArrowUp']) controlsRef.current.forward(speed, true);
if (keysPressed['KeyS'] || keysPressed['ArrowDown']) controlsRef.current.forward(-speed, true);
if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) controlsRef.current.truck(-speed, 0, true);
if (keysPressed['KeyD'] || keysPressed['ArrowRight']) controlsRef.current.truck(speed, 0, true);
});
// Process data into flat list of objects for rendering
const roomMeshes = useMemo(() => {
return data.rooms.map(room => {
// Room creates a floor area
const env = getMockRoomEnv(room.name);
// Determine floor color based on Env Visualization Mode
let floorColor = COLORS.ROOM_FLOOR;
let showLabel = true;
if (visMode === 'TEMP') {
// Map 65-85F range to Blue->Green->Red
const t = (env.temp - 65) / 20;
if (t < 0.5) floorColor = lerpColor(COLORS.TEMP_LOW, COLORS.TEMP_OPTIMAL, t * 2);
else floorColor = lerpColor(COLORS.TEMP_OPTIMAL, COLORS.TEMP_HIGH, (t - 0.5) * 2);
} else if (visMode === 'HUMIDITY') {
// Map 40-80% range to Amber->Blue
const t = (env.humidity - 40) / 40;
if (t < 0.5) floorColor = lerpColor(COLORS.HUMIDITY_DRY, COLORS.HUMIDITY_OPTIMAL, t * 2);
else floorColor = lerpColor(COLORS.HUMIDITY_OPTIMAL, COLORS.HUMIDITY_WET, (t - 0.5) * 2);
}
return (
<group key={room.id} position={[room.posX, 0, room.posY]}>
{/* Floor Label */}
<Text
position={[room.width / 2, 0.1, room.height / 2]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(room.width, room.height) / 5} // Scale text to room
fontSize={Math.min(room.width, room.height) / 5}
color="white"
fillOpacity={0.8}
anchorX="center"
anchorY="middle"
>
{room.name}
{showLabel ? room.name : ''}
</Text>
{/* Floor Plane - Darker for contrast */}
{/* Env Data Label Overlay */}
{(visMode === 'TEMP' || visMode === 'HUMIDITY') && (
<Text
position={[room.width / 2, 3, room.height / 2]}
rotation={[-Math.PI / 4, 0, 0]} // Tilted up
fontSize={2}
color="white"
outlineColor="#000"
outlineWidth={0.1}
anchorX="center"
anchorY="bottom"
>
{visMode === 'TEMP' ? `${env.temp.toFixed(1)}°F` : `${env.humidity.toFixed(0)}% RH`}
</Text>
)}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[room.width / 2, 0, room.height / 2]}
>
<planeGeometry args={[room.width, room.height]} />
<meshStandardMaterial color={COLORS.ROOM_FLOOR} metalness={0.2} roughness={0.8} />
<meshStandardMaterial
color={floorColor}
metalness={0.2}
roughness={0.8}
transparent={visMode !== 'STANDARD'}
opacity={visMode !== 'STANDARD' ? 0.8 : 1}
/>
</mesh>
{/* Room Border/Walls */}
<lineSegments position={[room.width / 2, 0.1, room.height / 2]} rotation={[-Math.PI / 2, 0, 0]}>
<edgesGeometry args={[new THREE.PlaneGeometry(room.width, room.height)]} />
<lineBasicMaterial color="#4b5563" linewidth={2} />
</lineSegments>
{/* Render sections and positions */}
{room.sections.map(section => {
// Calculate positions for this section
const positions = section.positions.map(pos => {
// Simple grid layout logic for position coordinates relative to section
// Assuming typical rack dimensions
const spacing = 0.5;
const x = section.posX + (pos.column * spacing);
const z = section.posY + (pos.row * spacing); // Z is depth/row
const y = 0.5 + (pos.tier * 0.5); // Y is height/tier
const z = section.posY + (pos.row * spacing);
const y = 0.5 + (pos.tier * 0.5);
return { ...pos, x, y, z };
});
return (
<group key={section.id}>
{/* Section Label - Lifted higher */}
<Text
position={[
section.posX + (section.width / 2),
3,
section.posY + (section.height / 2)
]}
position={[section.posX + (section.width / 2), 3, section.posY + (section.height / 2)]}
fontSize={0.8}
color="#9ca3af"
>
{section.code}
{visMode === 'STANDARD' ? section.code : ''}
</Text>
<PlantInstances positions={positions} onPlantClick={onSelectPlant} />
<PlantInstances positions={positions} onPlantClick={onSelectPlant} visMode={visMode} />
</group>
);
})}
</group>
);
});
}, [data, onSelectPlant]);
}, [data, onSelectPlant, visMode]);
return (
<>
@ -258,7 +304,6 @@ function FacilityScene({ data, onSelectPlant, targetView, setControls }: {
{roomMeshes}
</group>
{/* Changed from OrbitControls to CameraControls for better programmatic control */}
<CameraControls
ref={controlsRef}
minDistance={5}
@ -275,31 +320,23 @@ function FacilityScene({ data, onSelectPlant, targetView, setControls }: {
);
}
import { useSearchParams } from 'react-router-dom';
export default function Facility3DViewerPage() {
const [status, setStatus] = useState('Initializing...');
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
const [selectedPlant, setSelectedPlant] = useState<any | null>(null);
const [targetView, setTargetView] = useState<{ x: number, y: number, z: number, zoom?: boolean } | null>(null);
const [controls, setControls] = useState<CameraControls | null>(null);
const [visMode, setVisMode] = useState<VisMode>('STANDARD');
// URL Params for deep linking
const [searchParams] = useSearchParams();
const targetedPlantTag = searchParams.get('plant');
useEffect(() => {
loadData();
}, []);
useEffect(() => { loadData(); }, []);
// Effect to handle deep linking once data is loaded
useEffect(() => {
if (floorData && targetedPlantTag) {
// Find the plant in the data
let foundPlant: any = null;
let foundRoom: any = null;
// Search through all rooms and sections
for (const room of floorData.rooms) {
for (const section of room.sections) {
const match = section.positions.find(p => p.plant?.tagNumber === targetedPlantTag);
@ -313,30 +350,12 @@ export default function Facility3DViewerPage() {
}
if (foundPlant && foundRoom) {
// Focus on the room first
const offsetX = -floorData.floor.width / 2;
const offsetZ = -floorData.floor.height / 2;
// Calculate exact plant position
// section pos + relative plant pos
// But foundPlant is just the raw position data from the API, it doesn't have the calculated x/y/z from the render loop unless we process it.
// We need to re-calculate the absolute coordinates here to be safe, or move this logic to a helper
// Current simplistic approach: Focus the Room
// Select the plant to show the overlay
setSelectedPlant(foundPlant);
// Animate camera to the room
const roomCenterX = foundRoom.posX + (foundRoom.width / 2) + offsetX;
const roomCenterZ = foundRoom.posY + (foundRoom.height / 2) + offsetZ;
setTargetView({ x: roomCenterX, y: 0, z: roomCenterZ, zoom: true });
// Optional: slight delay then zoom closer?
console.log(`Deep link found plant: ${targetedPlantTag} in ${foundRoom.name}`);
} else {
console.warn(`Plant ${targetedPlantTag} not found in 3D data.`);
}
}
}, [floorData, targetedPlantTag]);
@ -360,25 +379,18 @@ export default function Facility3DViewerPage() {
}
const focusRoom = (room: any) => {
// Calculate room center relative to the scene group offset
// Scene group is offset by [-floor.width/2, 0, -floor.height/2]
if (!floorData) return;
const offsetX = -floorData.floor.width / 2;
const offsetZ = -floorData.floor.height / 2;
const centerX = room.posX + (room.width / 2) + offsetX;
const centerZ = room.posY + (room.height / 2) + offsetZ;
setTargetView({ x: centerX, y: 0, z: centerZ, zoom: true });
};
const resetView = () => {
setTargetView({ x: 0, y: 0, z: 0, zoom: false });
};
const resetView = () => setTargetView({ x: 0, y: 0, z: 0, zoom: false });
return (
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden">
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden font-sans">
{/* Header Overlay */}
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex items-center justify-between bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div className="pointer-events-auto flex items-center gap-4">
@ -396,16 +408,40 @@ export default function Facility3DViewerPage() {
</div>
</div>
{/* Legend */}
<div className="flex gap-4 text-xs bg-black/50 p-2 rounded-lg backdrop-blur pointer-events-auto">
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#4ade80]"></div> Veg</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#a855f7]"></div> Flower</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> Dry</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#374151]"></div> Empty</div>
{/* Legend - Dynamic based on Mode */}
<div className="flex gap-4 text-xs bg-black/60 p-2 rounded-lg backdrop-blur border border-white/10 pointer-events-auto shadow-lg">
{visMode === 'STANDARD' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#4ade80]"></div> Veg</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#a855f7]"></div> Flower</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> Dry</div>
</>
)}
{visMode === 'HEALTH' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#22c55e]"></div> Good</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#eab308]"></div> Check</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#ef4444]"></div> Critical</div>
</>
)}
{visMode === 'TEMP' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#3b82f6]"></div> &lt;65°F</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#22c55e]"></div> 75°F</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#ef4444]"></div> &gt;85°F</div>
</>
)}
{visMode === 'HUMIDITY' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> &lt;40%</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#0ea5e9]"></div> 60%</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#3b82f6]"></div> &gt;80%</div>
</>
)}
</div>
</div>
{/* Quick Nav Sidebar */}
{/* Quick Nav Sidebar (Standard) */}
{floorData && (
<div className="absolute top-24 left-4 z-10 w-48 space-y-2 pointer-events-auto animate-in slide-in-from-left-4">
<div className="bg-black/40 backdrop-blur rounded-lg p-3 border border-gray-800">
@ -415,7 +451,7 @@ export default function Facility3DViewerPage() {
<Maximize size={14} />
</button>
</div>
<div className="space-y-1 max-h-[60vh] overflow-y-auto">
<div className="space-y-1 max-h-[40vh] overflow-y-auto">
{floorData.rooms.map(room => (
<button
key={room.id}
@ -428,20 +464,58 @@ export default function Facility3DViewerPage() {
))}
</div>
</div>
{/* Controls Help */}
<div className="bg-black/40 backdrop-blur rounded-lg p-3 border border-gray-800 text-xs text-gray-500">
<div className="font-bold mb-1 text-gray-400">Controls</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1">
<span>Rotate</span> <span className="text-right text-gray-300">Left Click</span>
<span>Pan</span> <span className="text-right text-gray-300">Right Click</span>
<span>Zoom</span> <span className="text-right text-gray-300">Scroll</span>
<span>Move</span> <span className="text-right text-gray-300">W A S D</span>
</div>
</div>
</div>
)}
{/* Data Vis Controls (Floating Right) */}
<div className="absolute top-24 right-4 z-10 w-16 flex flex-col gap-2 pointer-events-auto animate-in slide-in-from-right-4">
<button
onClick={() => setVisMode('STANDARD')}
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'STANDARD' ? 'bg-primary/90 border-primary text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
title="Standard View"
>
<Layers size={24} className="mx-auto" />
<span className="text-[10px] block text-center mt-1">Layout</span>
</button>
<button
onClick={() => setVisMode('HEALTH')}
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'HEALTH' ? 'bg-red-500/90 border-red-400 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
title="Plant Health"
>
<Activity size={24} className="mx-auto" />
<span className="text-[10px] block text-center mt-1">Health</span>
</button>
<button
onClick={() => setVisMode('TEMP')}
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'TEMP' ? 'bg-orange-500/90 border-orange-400 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
title="Temperature Map"
>
<Thermometer size={24} className="mx-auto" />
<span className="text-[10px] block text-center mt-1">Temp</span>
</button>
<button
onClick={() => setVisMode('HUMIDITY')}
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'HUMIDITY' ? 'bg-blue-500/90 border-blue-400 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
title="Humidity Map"
>
<Droplets size={24} className="mx-auto" />
<span className="text-[10px] block text-center mt-1">VPD</span>
</button>
<button
onClick={() => setVisMode('YIELD')}
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'YIELD' ? 'bg-green-600/90 border-green-500 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
title="Yield Forecast"
>
<Leaf size={24} className="mx-auto" />
<span className="text-[10px] block text-center mt-1">Yield</span>
</button>
</div>
{/* Error/Status Overlay */}
{status && (
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/80 backdrop-blur-sm">
@ -461,31 +535,41 @@ export default function Facility3DViewerPage() {
{selectedPlant && (
<div className="absolute bottom-4 right-4 z-20 w-80 bg-gray-900/90 border border-gray-700 rounded-lg p-4 shadow-xl backdrop-blur-md animate-in slide-in-from-right-10 pointer-events-auto">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-accent">{selectedPlant.plant.tagNumber}</h3>
<button
onClick={() => setSelectedPlant(null)}
className="text-gray-400 hover:text-white"
>
×
</button>
<div>
<span className="text-xs uppercase tracking-wider text-gray-500">{selectedPlant.plant.metrcTag ? 'METRC TAG' : 'PLANT ID'}</span>
<h3 className="font-bold text-accent text-lg">{selectedPlant.plant.tagNumber}</h3>
</div>
<button onClick={() => setSelectedPlant(null)} className="text-gray-400 hover:text-white">×</button>
</div>
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div className="bg-black/30 p-2 rounded">
<span className="text-gray-400 text-xs block">Strain</span>
<span className="font-medium text-white">{selectedPlant.plant.strain || 'Unknown'}</span>
</div>
<div className="bg-black/30 p-2 rounded">
<span className="text-gray-400 text-xs block">Stage</span>
<span className={`badge badge-xs ${selectedPlant.plant.stage === 'FLOWER' ? 'badge-primary' : 'badge-accent'}`}>
{selectedPlant.plant.stage || 'N/A'}
</span>
</div>
</div>
<div className="space-y-2 text-sm mt-4 border-t border-gray-700 pt-4">
<div className="flex justify-between">
<span className="text-gray-400">Strain:</span>
<span>{selectedPlant.plant.strain || 'Unknown'}</span>
<span className="text-gray-400">Batch</span>
<span className="text-right">{selectedPlant.plant.batchName || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Stage:</span>
<span className="badge badge-accent bg-opacity-20">{selectedPlant.plant.stage || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Batch:</span>
<span>{selectedPlant.plant.batchName || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Location:</span>
<span className="font-mono text-xs">R{selectedPlant.row} T{selectedPlant.tier} S{selectedPlant.slot}</span>
<span className="text-gray-400">Position</span>
<span className="font-mono text-xs text-right">R{selectedPlant.row} C{selectedPlant.column} T{selectedPlant.tier}</span>
</div>
{visMode === 'HEALTH' && (
<div className="flex justify-between items-center mt-2 bg-red-900/20 p-2 rounded border border-red-900/50">
<span className="text-red-400 flex items-center gap-2"><Activity size={14} /> Risk Factor</span>
<span className="text-red-300 font-bold">High</span>
</div>
)}
</div>
</div>
)}
@ -499,6 +583,7 @@ export default function Facility3DViewerPage() {
onSelectPlant={setSelectedPlant}
targetView={targetView}
setControls={setControls}
visMode={visMode}
/>
)}
</Suspense>