ca-grow-ops-manager/frontend/src/pages/Facility3DViewerPage.tsx
fullsizemalt 6d107c637b
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: add hierarchical drilldown navigation
- New HierarchyNav component with Facility→Building→Floor→Rooms path
- Back button and breadcrumb trail in nav header
- Replace old room list with drilldown navigation
- Each level shows children with chevron indicators
2025-12-19 10:56:42 -08:00

521 lines
25 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 } 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';
// --- 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 SCALE = 0.1;
const x = (room.posX + section.posX + (match.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (room.posY + section.posY + (match.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2);
const y = 0.4 + (match.tier * 0.6);
const plant: PlantPosition = { ...match, x, y, z };
setSelectedPlant(plant);
setFocusTarget({ x, z });
setCameraPreset('ROOM_FOCUS');
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('ROOM_FOCUS');
// 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;
const SCALE = 0.1;
const x = (result.position.roomX + result.position.sectionX + (result.position.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (result.position.roomY + result.position.sectionY + (result.position.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2);
const y = 0.4 + (result.position.tier * 0.6);
setFocusTarget({ x, z });
setCameraPreset('ROOM_FOCUS');
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);
if (preset !== 'ROOM_FOCUS') {
setFocusTarget(null);
}
}}
/>
</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) => {
// Calculate position and select plant
const room = floorData.rooms.find(r => r.name === roomName);
const section = room?.sections.find(s => (s.code || s.name) === sectionCode);
if (room && section) {
const SCALE = 0.1;
const x = (room.posX + section.posX + (plant.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2);
const z = (room.posY + section.posY + (plant.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2);
const y = 0.4 + (plant.tier * 0.6);
setFocusTarget({ x, z });
setCameraPreset('ROOM_FOCUS');
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>
);
}