ca-grow-ops-manager/frontend/src/pages/Facility3DViewerPage.tsx
fullsizemalt d44238417b
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
feat(ui): Add Feature3D intro to 3D Viewer
- Added Feature3D AuraUI component with framer-motion animations
- Integrated as a landing overlay on Facility3DViewerPage
2025-12-19 17:22:22 -08:00

574 lines
26 KiB
TypeScript

// ... imports ...
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, Menu } 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 { PlantDataCard } from '../components/facility3d/PlantDataCard';
import { SCALE } from '../components/facility3d/coordinates';
import Feature3D from '../components/aura/Feature3D';
// ... calculatePlantCoords and ErrorBoundary remain unchanged ...
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 [showIntro, setShowIntro] = useState(true);
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');
// Mobile Nav State
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// 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(() => {
// If coming from deep link, skip intro
if (targetedPlantTag) {
setShowIntro(false);
}
}, [targetedPlantTag]);
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);
// Mobile UX: Close sidebar after selection
setIsSidebarOpen(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 section - prefer ID lookup for reliability, fall back to code/name
let section: Section3D | undefined;
for (const room of floorData.rooms) {
if (result.sectionId) {
section = room.sections.find(s => s.id === result.sectionId);
} else {
// Fallback: must match both room and section code
if (room.name === result.roomName) {
section = room.sections.find(s => (s.code || s.name) === result.sectionCode);
}
}
if (section) break;
}
if (!section) {
console.warn('[handleSearchSelect] Could not find section for result:', result);
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">
{/* Intro Overlay */}
{showIntro && (
<div className="absolute inset-0 z-50 bg-slate-950 overflow-y-auto">
<Feature3D onEnter={() => setShowIntro(false)} />
</div>
)}
{/* Header */}
<div className="absolute top-0 left-0 right-0 p-3 md:p-4 z-10 flex flex-wrap items-center justify-between bg-gradient-to-b from-black/90 to-transparent pointer-events-none gap-2">
<div className="pointer-events-auto flex items-center gap-3">
{/* Mobile Menu Toggle */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="md:hidden p-2 bg-slate-800/80 rounded-lg text-white"
>
<Menu size={18} />
</button>
<Link to="/metrc" className="btn btn-ghost btn-sm text-white px-2">
<ArrowLeft size={16} /> <span className="hidden sm:inline">Back</span>
</Link>
<div>
<h1 className="text-sm md:text-xl font-bold flex items-center gap-2">
<span className="hidden sm:inline">Facility 3D</span>
<span className="sm:hidden">3D View</span>
<span className="badge badge-accent text-[10px] md:text-xs">BETA</span>
</h1>
<div className="flex items-center gap-2 flex-wrap">
{allFloors.length > 1 ? (
<select
value={selectedFloorId || ''}
onChange={(e) => setSelectedFloorId(e.target.value)}
className="select select-xs bg-slate-800 border-slate-600 text-white max-w-[120px] md:max-w-none"
>
{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-[10px] md:text-xs text-slate-400 hidden sm:inline">
{floorData?.stats.occupiedPositions || 0} Plants
</span>
</div>
</div>
</div>
{/* Search Bar - Flexible width on mobile */}
{floorData && (
<div className="pointer-events-auto flex-1 max-w-[200px] md:max-w-md mx-2 md:mx-0">
<PlantSearch
floorData={floorData}
onSelectResult={handleSearchSelect}
onHighlightResults={handleHighlightResults}
/>
</div>
)}
{/* Camera Preset Selector - Hide on very small screens if crowded */}
<div className="pointer-events-auto hidden sm:block">
<CameraPresetSelector
current={cameraPreset}
onChange={(preset) => {
setCameraPreset(preset);
}}
/>
</div>
</div>
{/* Legend - Responsive positioning */}
<div className="absolute top-[3.5rem] right-2 md:top-4 md:right-auto md:left-1/2 md:-translate-x-1/2 pointer-events-none z-10 hidden lg:block">
<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>
</>
)}
{/* ... other legend items ... */}
</div>
</div>
{/* Room Navigation Sidebar - Responsive Drawer */}
{floorData && (
<div className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-slate-950/95 backdrop-blur-xl border-r border-slate-800 p-4 pt-20 transition-transform duration-300 ease-in-out md:pt-0 md:bg-transparent md:backdrop-blur-none md:border-none md:top-24 md:left-4 md:w-52 md:h-auto md:bottom-auto pointer-events-auto ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
}`}>
<div className="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) => {
// ... check logic ...
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,
},
});
setIsSidebarOpen(false); // Close mobile drawer
}
}}
/>
</div>
</div>
</div>
)}
{/* Sidebar Overlay for Mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 md:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Visualization Mode Buttons - Responsive Position */}
<div className="absolute top-28 right-2 md:top-24 md:right-4 z-10 w-10 md: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 md:p-2.5 rounded-lg md: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={18} className="mx-auto md:w-6 md:h-6" />
<span className="text-[8px] md:text-[9px] block text-center mt-0.5 hidden md:block">{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 md:p-2.5 rounded-lg md: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={16} className="mx-auto md:w-6 md:h-6" />
<span className="text-[8px] md:text-[9px] block text-center mt-0.5 hidden md:block">Time</span>
</button>
</div>
</div>
{/* Loading/Error Overlay - Unchanged */}
{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 Data Card - Responsive Bottom Sheet/Side Panel */}
{selectedPlant?.plant && (
<div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-auto md:bottom-4 md:right-4 md:left-auto">
<PlantDataCard
plant={selectedPlant}
onClose={() => {
setSelectedPlant(null);
setBeaconPosition(null);
}}
/>
</div>
)}
{/* 3D Canvas - Unchanged */}
<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}
timelineDate={showTimeline ? timelineDate : undefined}
/>
)}
</Suspense>
</ErrorBoundary>
</Canvas>
{/* Timeline Slider - Responsive Width */}
{showTimeline && (
<div className="absolute bottom-20 md:bottom-4 left-1/2 -translate-x-1/2 z-20 w-[95%] md:w-[90%] max-w-4xl pointer-events-auto transition-all duration-300">
<TimelineSlider
onDateChange={setTimelineDate}
isPlaying={isTimelinePlaying}
onPlayPause={() => setIsTimelinePlaying(!isTimelinePlaying)}
/>
</div>
)}
</div>
);
}