ca-grow-ops-manager/frontend/src/pages/Facility3DViewerPage.tsx
fullsizemalt 5f774bb873
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
fix: beacon coordinates now match plant positions exactly
- Create coordinates.ts with centralized SCALE constant
- Add calculatePlantCoords() helper matching SmartRack grid logic
- Refactor all beacon position calcs to use helper
- Uses col*colSpacing where colSpacing = width/(maxCols+1)
- This matches how plants are actually rendered in PlantSystem
2025-12-19 11:52:24 -08:00

572 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, Suspense, Component, ReactNode, useCallback } from 'react';
import { Canvas } from '@react-three/fiber';
import { Html, CameraControls } from '@react-three/drei';
import { layoutApi, Floor3DData, Room3D, Position3D, Section3D } from '../lib/layoutApi';
import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf, Eye, EyeOff, Clock } from 'lucide-react';
import { Link, useSearchParams } from 'react-router-dom';
import { FacilityScene } from '../components/facility3d/FacilityScene';
import { PlantSearch } from '../components/facility3d/PlantSearch';
import { PlantLibrary } from '../components/facility3d/PlantLibrary';
import { HierarchyNav } from '../components/facility3d/HierarchyNav';
import { TimelineSlider } from '../components/facility3d/TimelineSlider';
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets';
import { HierarchyBreadcrumb } from '../components/facility3d/HierarchyBreadcrumb';
import { SCALE } from '../components/facility3d/coordinates';
/**
* Calculate plant world coordinates matching SmartRack's grid logic exactly.
* This ensures beacon position matches where plants are actually rendered.
*/
function calculatePlantCoords(
section: Section3D,
position: { row: number; column: number; tier: number },
floorWidth: number,
floorHeight: number
): { x: number; y: number; z: number } {
// Scale section to world units (same as SmartRack)
const scaledSection = {
posX: section.posX * SCALE,
posY: section.posY * SCALE,
width: section.width * SCALE,
height: section.height * SCALE,
};
// Calculate grid dimensions (same as SmartRack)
const maxCols = Math.max(...section.positions.map(p => p.column), 1);
const maxRows = Math.max(...section.positions.map(p => p.row), 1);
// Calculate spacing (same as SmartRack)
const colSpacing = scaledSection.width / (maxCols + 1);
const rowSpacing = scaledSection.height / (maxRows + 1);
// Raw position within section (same as SmartRack)
const rawX = scaledSection.posX + (position.column * colSpacing);
const rawZ = scaledSection.posY + (position.row * rowSpacing);
const y = 0.4 + (position.tier * 0.6);
// Apply floor centering (matches FacilityScene offset group)
const floorCenterX = (floorWidth * SCALE) / 2;
const floorCenterZ = (floorHeight * SCALE) / 2;
return {
x: rawX - floorCenterX,
y,
z: rawZ - floorCenterZ,
};
}
// --- Error Boundary ---
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<Html center>
<div className="bg-red-900/90 text-white p-4 rounded border border-red-500 max-w-md">
<h3 className="font-bold mb-2">3D Rendering Error</h3>
<p className="text-sm font-mono break-words">{this.state.error?.message}</p>
</div>
</Html>
);
}
return this.props.children;
}
}
// --- Main Component ---
export default function Facility3DViewerPage() {
const [status, setStatus] = useState('Initializing...');
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
const [selectedPlant, setSelectedPlant] = useState<PlantPosition | null>(null);
const [visMode, setVisMode] = useState<VisMode>('STANDARD');
// Phase 2: Search state
const [highlightedTags, setHighlightedTags] = useState<string[]>([]);
const [dimMode, setDimMode] = useState(false);
const [beaconPosition, setBeaconPosition] = useState<[number, number, number] | null>(null);
// Phase 3: Timeline state
const [showTimeline, setShowTimeline] = useState(false);
const [timelineDate, setTimelineDate] = useState<Date>(new Date());
const [isTimelinePlaying, setIsTimelinePlaying] = useState(false);
// Floor selection state
const [allFloors, setAllFloors] = useState<{ id: string; name: string; buildingName: string }[]>([]);
const [selectedFloorId, setSelectedFloorId] = useState<string | null>(null);
// Camera preset state
const [cameraPreset, setCameraPreset] = useState<CameraPreset>('ISOMETRIC');
const [focusTarget, setFocusTarget] = useState<{ x: number; z: number } | null>(null);
const [searchParams] = useSearchParams();
const targetedPlantTag = searchParams.get('plant');
useEffect(() => {
loadData();
}, []);
// Load floor data when selected floor changes
useEffect(() => {
if (selectedFloorId) {
loadFloorData(selectedFloorId);
}
}, [selectedFloorId]);
// Deep link handler
useEffect(() => {
if (floorData && targetedPlantTag) {
for (const room of floorData.rooms) {
for (const section of room.sections) {
const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag);
if (match) {
const { x, y, z } = calculatePlantCoords(
section,
{ row: match.row, column: match.column, tier: match.tier },
floorData.floor.width,
floorData.floor.height
);
const plant: PlantPosition = { ...match, x, y, z };
setSelectedPlant(plant);
setFocusTarget({ x, z });
setCameraPreset('ISOMETRIC');
setBeaconPosition([x, y, z]);
setHighlightedTags([targetedPlantTag]);
setDimMode(true);
return;
}
}
}
}
}, [floorData, targetedPlantTag]);
async function loadFloorData(floorId: string) {
setStatus('Fetching 3D scene...');
try {
const data = await layoutApi.getFloor3D(floorId);
setFloorData(data);
setStatus('');
} catch (err) {
setStatus('Error: ' + (err as Error).message);
}
}
async function loadData() {
setStatus('Loading layout...');
try {
const props = await layoutApi.getProperties();
if (props[0]?.buildings?.length > 0) {
// Collect all floors from all buildings
const floors: { id: string; name: string; buildingName: string }[] = [];
for (const building of props[0].buildings) {
for (const floor of building.floors || []) {
floors.push({
id: floor.id,
name: floor.name,
buildingName: building.name
});
}
}
setAllFloors(floors);
// Default to second floor if available (Upper Floor has more plants)
if (floors.length > 0) {
const defaultFloor = floors.length > 1 ? floors[1] : floors[0];
setSelectedFloorId(defaultFloor.id);
}
} else {
setStatus('No floor layout found. Set up a facility first.');
}
} catch (err) {
setStatus('Error: ' + (err as Error).message);
}
}
const focusRoom = (room: Room3D) => {
if (!floorData) return;
const SCALE = 0.1;
// Calculate focus target in scaled world coordinates
const x = (room.posX + room.width / 2) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (room.posY + room.height / 2) * SCALE - (floorData.floor.height * SCALE / 2);
setFocusTarget({ x, z });
setCameraPreset('ISOMETRIC');
// Clear search state when navigating by room
setBeaconPosition(null);
setHighlightedTags([]);
setDimMode(false);
};
const resetView = () => {
setCameraPreset('ISOMETRIC');
setFocusTarget(null);
setBeaconPosition(null);
setHighlightedTags([]);
setDimMode(false);
setSelectedPlant(null);
};
// Breadcrumb navigation handler
const handleBreadcrumbNavigate = (level: string) => {
if (!floorData || !selectedPlant) return;
if (level === 'room' && selectedPlant.breadcrumb?.room) {
// Find the room by name and focus on it
const room = floorData.rooms.find(r => r.name === selectedPlant.breadcrumb?.room);
if (room) {
focusRoom(room);
}
} else if (level === 'floor' || level === 'building' || level === 'facility') {
// Return to floor overview
setCameraPreset('ISOMETRIC');
setFocusTarget(null);
}
// Section/tier clicks stay on current view but could highlight
};
// Search result selection handler
const handleSearchSelect = useCallback((result: any) => {
if (!floorData) return;
// Find the actual section to use grid-based position calculation
const room = floorData.rooms.find(r => r.name === result.roomName);
const section = room?.sections.find(s => (s.code || s.name) === result.sectionCode);
if (!section) return;
const { x, y, z } = calculatePlantCoords(
section,
{ row: result.position.row, column: result.position.column, tier: result.position.tier },
floorData.floor.width,
floorData.floor.height
);
setFocusTarget({ x, z });
setCameraPreset('ISOMETRIC');
setBeaconPosition([x, y, z]);
setHighlightedTags([result.label]);
setDimMode(true);
// Include breadcrumb data from search result
setSelectedPlant({
...result.plant,
x,
y,
z,
breadcrumb: {
room: result.roomName,
section: result.sectionCode,
},
});
}, [floorData]);
// Highlight handler for search typing
const handleHighlightResults = useCallback((tags: string[]) => {
setHighlightedTags(tags);
if (tags.length > 0) {
setDimMode(true);
} else {
setDimMode(false);
setBeaconPosition(null);
}
}, []);
return (
<div className="h-screen w-full relative bg-slate-950 text-white overflow-hidden font-sans">
{/* Header */}
<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">
<Link to="/metrc" className="btn btn-ghost btn-sm text-white">
<ArrowLeft size={16} /> Back
</Link>
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
Facility 3D
<span className="badge badge-accent text-xs">BETA</span>
</h1>
<div className="flex items-center gap-2">
{allFloors.length > 1 ? (
<select
value={selectedFloorId || ''}
onChange={(e) => setSelectedFloorId(e.target.value)}
className="select select-xs bg-slate-800 border-slate-600 text-white"
>
{allFloors.map(f => (
<option key={f.id} value={f.id}>
{f.buildingName} - {f.name}
</option>
))}
</select>
) : (
<span className="text-xs text-slate-400">
{floorData?.floor.name || 'Loading...'}
</span>
)}
<span className="text-xs text-slate-400">
{floorData?.stats.occupiedPositions || 0} Plants
</span>
</div>
</div>
</div>
{/* Search Bar */}
{floorData && (
<div className="pointer-events-auto">
<PlantSearch
floorData={floorData}
onSelectResult={handleSearchSelect}
onHighlightResults={handleHighlightResults}
/>
</div>
)}
{/* Camera Preset Selector */}
<div className="pointer-events-auto">
<CameraPresetSelector
current={cameraPreset}
onChange={(preset) => {
setCameraPreset(preset);
}}
/>
</div>
{/* Legend */}
<div className="flex gap-3 text-xs bg-black/60 p-2 rounded-lg backdrop-blur border border-white/10 pointer-events-auto">
{visMode === 'STANDARD' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.VEG }} /> Veg</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.FLOWER }} /> Flower</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.DRY }} /> Dry</div>
</>
)}
{visMode === 'HEALTH' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_GOOD }} /> Good</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_WARN }} /> Check</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_CRIT }} /> Critical</div>
</>
)}
{visMode === 'TEMP' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_LOW }} /> &lt;65°F</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_OPTIMAL }} /> 75°F</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_HIGH }} /> &gt;85°F</div>
</>
)}
{visMode === 'HUMIDITY' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_DRY }} /> &lt;40%</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_OPTIMAL }} /> 60%</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_WET }} /> &gt;80%</div>
</>
)}
</div>
</div>
{/* Room Navigation Sidebar */}
{floorData && (
<div className="absolute top-24 left-4 z-10 w-52 pointer-events-auto space-y-2">
{/* Hierarchical Navigation */}
<HierarchyNav
floorData={floorData}
onRoomSelect={focusRoom}
onResetView={resetView}
/>
{/* Focus Mode Toggle */}
{highlightedTags.length > 0 && (
<div className="mt-2 bg-cyan-900/50 backdrop-blur rounded-lg p-2 border border-cyan-700/50">
<button
onClick={() => setDimMode(!dimMode)}
className="w-full flex items-center justify-between text-sm text-cyan-200"
>
<span>{highlightedTags.length} result{highlightedTags.length !== 1 ? 's' : ''}</span>
{dimMode ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
{/* Plant Library Browser */}
<div className="mt-2">
<PlantLibrary
floorData={floorData}
onPlantSelect={(plant, roomName, sectionCode) => {
// Find section and calculate position using same logic as SmartRack
const room = floorData.rooms.find(r => r.name === roomName);
const section = room?.sections.find(s => (s.code || s.name) === sectionCode);
if (section) {
const { x, y, z } = calculatePlantCoords(
section,
{ row: plant.row, column: plant.column, tier: plant.tier },
floorData.floor.width,
floorData.floor.height
);
setFocusTarget({ x, z });
setCameraPreset('ISOMETRIC');
setBeaconPosition([x, y, z]);
setDimMode(false);
setHighlightedTags([plant.plant?.tagNumber || '']);
setSelectedPlant({
...plant,
x,
y,
z,
breadcrumb: {
room: roomName,
section: sectionCode,
},
});
}
}}
/>
</div>
</div>
)}
{/* Visualization Mode Buttons */}
<div className="absolute top-24 right-4 z-10 w-14 flex flex-col gap-2 pointer-events-auto">
{[
{ mode: 'STANDARD' as VisMode, icon: Layers, label: 'Layout', active: 'bg-primary' },
{ mode: 'HEALTH' as VisMode, icon: Activity, label: 'Health', active: 'bg-red-500' },
{ mode: 'TEMP' as VisMode, icon: Thermometer, label: 'Temp', active: 'bg-orange-500' },
{ mode: 'HUMIDITY' as VisMode, icon: Droplets, label: 'VPD', active: 'bg-blue-500' },
{ mode: 'YIELD' as VisMode, icon: Leaf, label: 'Yield', active: 'bg-green-600' },
].map(({ mode, icon: Icon, label, active }) => (
<button
key={mode}
onClick={() => setVisMode(mode)}
className={`p-2.5 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === mode
? `${active} border-white/30 text-white scale-105`
: 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70'
}`}
title={label}
>
<Icon size={22} className="mx-auto" />
<span className="text-[9px] block text-center mt-0.5">{label}</span>
</button>
))}
{/* Timeline Toggle */}
<div className="border-t border-slate-600 mt-1 pt-1">
<button
onClick={() => setShowTimeline(!showTimeline)}
className={`w-full p-2.5 rounded-xl backdrop-blur-md shadow-lg transition-all border ${showTimeline
? 'bg-cyan-600 border-cyan-400 text-white scale-105'
: 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70'
}`}
title="Time Machine"
>
<Clock size={22} className="mx-auto" />
<span className="text-[9px] block text-center mt-0.5">Time</span>
</button>
</div>
</div>
{/* Loading/Error Overlay */}
{status && (
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/85 backdrop-blur-sm">
<div className="text-center">
<Loader2 size={36} className="animate-spin text-accent mx-auto mb-3" />
<p className="text-slate-300">{status}</p>
{status.includes('Error') && (
<button onClick={() => window.location.reload()} className="btn btn-sm btn-outline mt-4">
Retry
</button>
)}
</div>
</div>
)}
{/* Plant Selection Panel */}
{selectedPlant?.plant && (
<div className="absolute bottom-4 right-4 z-20 w-80 bg-slate-900/95 border border-slate-700 rounded-lg p-4 shadow-xl backdrop-blur-md pointer-events-auto">
{/* Breadcrumb */}
{selectedPlant.breadcrumb && (
<div className="mb-3">
<HierarchyBreadcrumb
data={{
facility: floorData?.floor.property,
building: floorData?.floor.building,
floor: floorData?.floor.name,
room: selectedPlant.breadcrumb.room,
section: selectedPlant.breadcrumb.section,
tier: selectedPlant.tier,
}}
onNavigate={handleBreadcrumbNavigate}
/>
</div>
)}
<div className="flex justify-between items-start mb-3">
<div>
<span className="text-[10px] uppercase tracking-wider text-slate-500">
{selectedPlant.plant.batchId ? 'METRC TAG' : 'PLANT ID'}
</span>
<h3 className="font-bold text-accent text-lg leading-tight">{selectedPlant.plant.tagNumber}</h3>
</div>
<button onClick={() => { setSelectedPlant(null); setBeaconPosition(null); }} className="text-slate-400 hover:text-white text-lg">×</button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-black/30 p-2 rounded">
<span className="text-slate-500 text-xs block">Strain</span>
<span className="font-medium">{selectedPlant.plant.strain || 'Unknown'}</span>
</div>
<div className="bg-black/30 p-2 rounded">
<span className="text-slate-500 text-xs block">Stage</span>
<span className={`badge badge-xs ${selectedPlant.plant.stage === 'FLOWERING' ? 'badge-primary' : 'badge-accent'}`}>
{selectedPlant.plant.stage || 'N/A'}
</span>
</div>
</div>
<div className="mt-3 pt-3 border-t border-slate-700 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-slate-500">Batch</span>
<span>{selectedPlant.plant.batchName || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Position</span>
<span className="font-mono text-xs">R{selectedPlant.row} C{selectedPlant.column} T{selectedPlant.tier}</span>
</div>
</div>
</div>
)}
{/* 3D Canvas */}
<Canvas camera={{ position: [25, 35, 25], fov: 55 }} shadows>
<ErrorBoundary>
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
{floorData && (
<FacilityScene
data={floorData}
visMode={visMode}
cameraPreset={cameraPreset}
focusTarget={focusTarget}
onPlantClick={setSelectedPlant}
highlightedTags={highlightedTags}
dimMode={dimMode}
beaconPosition={beaconPosition}
/>
)}
</Suspense>
</ErrorBoundary>
</Canvas>
{/* Timeline Slider - Phase 3 */}
{showTimeline && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 w-[90%] max-w-4xl pointer-events-auto">
<TimelineSlider
onDateChange={setTimelineDate}
isPlaying={isTimelinePlaying}
onPlayPause={() => setIsTimelinePlaying(!isTimelinePlaying)}
/>
</div>
)}
</div>
);
}