- 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
521 lines
25 KiB
TypeScript
521 lines
25 KiB
TypeScript
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 }} /> <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 }} /> >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 }} /> <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 }} /> >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>
|
||
);
|
||
}
|