diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index 1445e1c..dc18526 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -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 ( - {/* Active Plants - Render as Cylinders (Stalks/Canopy) for visibility */} + {/* Active Plants */} {plants.length > 0 && ( - {/* Make plants much taller and visible: Cylinder radius 0.2, height 0.8 */} - {plants.map((pos, i) => ( - - ))} + {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 ( + + ); + })} )} - {/* Empty Slots - Keep subtle but visible */} - {emptySlots.length > 0 && ( + {/* Empty Slots */} + {emptySlots.length > 0 && visMode === 'STANDARD' && ( @@ -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(null); const [keysPressed, setKeysPressed] = useState>({}); - // 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 ( - {/* Floor Label */} - {room.name} + {showLabel ? room.name : ''} - {/* Floor Plane - Darker for contrast */} + {/* Env Data Label Overlay */} + {(visMode === 'TEMP' || visMode === 'HUMIDITY') && ( + + {visMode === 'TEMP' ? `${env.temp.toFixed(1)}°F` : `${env.humidity.toFixed(0)}% RH`} + + )} + - + - {/* Room Border/Walls */} - {/* 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 ( - {/* Section Label - Lifted higher */} - {section.code} + {visMode === 'STANDARD' ? section.code : ''} - + ); })} ); }); - }, [data, onSelectPlant]); + }, [data, onSelectPlant, visMode]); return ( <> @@ -258,7 +304,6 @@ function FacilityScene({ data, onSelectPlant, targetView, setControls }: { {roomMeshes} - {/* Changed from OrbitControls to CameraControls for better programmatic control */} (null); const [selectedPlant, setSelectedPlant] = useState(null); const [targetView, setTargetView] = useState<{ x: number, y: number, z: number, zoom?: boolean } | null>(null); const [controls, setControls] = useState(null); + const [visMode, setVisMode] = useState('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 ( -
+
{/* Header Overlay */}
@@ -396,16 +408,40 @@ export default function Facility3DViewerPage() {
- {/* Legend */} -
-
Veg
-
Flower
-
Dry
-
Empty
+ {/* Legend - Dynamic based on Mode */} +
+ {visMode === 'STANDARD' && ( + <> +
Veg
+
Flower
+
Dry
+ + )} + {visMode === 'HEALTH' && ( + <> +
Good
+
Check
+
Critical
+ + )} + {visMode === 'TEMP' && ( + <> +
<65°F
+
75°F
+
>85°F
+ + )} + {visMode === 'HUMIDITY' && ( + <> +
<40%
+
60%
+
>80%
+ + )}
- {/* Quick Nav Sidebar */} + {/* Quick Nav Sidebar (Standard) */} {floorData && (
@@ -415,7 +451,7 @@ export default function Facility3DViewerPage() {
-
+
{floorData.rooms.map(room => (
- - {/* Controls Help */} -
-
Controls
-
- Rotate Left Click - Pan Right Click - Zoom Scroll - Move W A S D -
-
)} + {/* Data Vis Controls (Floating Right) */} +
+ + + + + + + + + +
+ + {/* Error/Status Overlay */} {status && (
@@ -461,31 +535,41 @@ export default function Facility3DViewerPage() { {selectedPlant && (
-

{selectedPlant.plant.tagNumber}

- +
+ {selectedPlant.plant.metrcTag ? 'METRC TAG' : 'PLANT ID'} +

{selectedPlant.plant.tagNumber}

+
+
-
+ +
+
+ Strain + {selectedPlant.plant.strain || 'Unknown'} +
+
+ Stage + + {selectedPlant.plant.stage || 'N/A'} + +
+
+ +
- Strain: - {selectedPlant.plant.strain || 'Unknown'} + Batch + {selectedPlant.plant.batchName || '-'}
- Stage: - {selectedPlant.plant.stage || 'N/A'} -
-
- Batch: - {selectedPlant.plant.batchName || '-'} -
-
- Location: - R{selectedPlant.row} T{selectedPlant.tier} S{selectedPlant.slot} + Position + R{selectedPlant.row} • C{selectedPlant.column} • T{selectedPlant.tier}
+ {visMode === 'HEALTH' && ( +
+ Risk Factor + High +
+ )}
)} @@ -499,6 +583,7 @@ export default function Facility3DViewerPage() { onSelectPlant={setSelectedPlant} targetView={targetView} setControls={setControls} + visMode={visMode} /> )}