diff --git a/frontend/src/components/facility3d/Beacon.tsx b/frontend/src/components/facility3d/Beacon.tsx new file mode 100644 index 0000000..cc33b72 --- /dev/null +++ b/frontend/src/components/facility3d/Beacon.tsx @@ -0,0 +1,60 @@ +import { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; + +interface BeaconProps { + position: [number, number, number]; + color?: string; + height?: number; +} + +export function Beacon({ position, color = '#22d3ee', height = 15 }: BeaconProps) { + const meshRef = useRef(null); + const ringRef = useRef(null); + + // Animate the beacon + useFrame((state) => { + if (meshRef.current) { + // Pulse opacity + const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.3 + 0.5; + (meshRef.current.material as THREE.MeshBasicMaterial).opacity = pulse; + } + if (ringRef.current) { + // Expand and fade ring + const t = (state.clock.elapsedTime % 2) / 2; + ringRef.current.scale.set(1 + t * 3, 1, 1 + t * 3); + (ringRef.current.material as THREE.MeshBasicMaterial).opacity = 0.6 * (1 - t); + } + }); + + return ( + + {/* Vertical Light Beam */} + + + + + + {/* Ground Ring */} + + + + + + {/* Core Glow */} + + + ); +} diff --git a/frontend/src/components/facility3d/FacilityScene.tsx b/frontend/src/components/facility3d/FacilityScene.tsx index 1646528..3f40cd6 100644 --- a/frontend/src/components/facility3d/FacilityScene.tsx +++ b/frontend/src/components/facility3d/FacilityScene.tsx @@ -3,6 +3,7 @@ import { useFrame } from '@react-three/fiber'; import { CameraControls, Environment, ContactShadows } from '@react-three/drei'; import type { Floor3DData, Room3D } from '../../lib/layoutApi'; import { RoomObject } from './RoomObject'; +import { Beacon } from './Beacon'; import { PlantPosition, VisMode } from './types'; interface FacilitySceneProps { @@ -11,6 +12,9 @@ interface FacilitySceneProps { targetView: { x: number; y: number; z: number; zoom?: boolean } | null; onPlantClick: (plant: PlantPosition) => void; onControlsReady: (controls: CameraControls) => void; + highlightedTags?: string[]; + dimMode?: boolean; + beaconPosition?: [number, number, number] | null; } export function FacilityScene({ @@ -19,6 +23,9 @@ export function FacilityScene({ targetView, onPlantClick, onControlsReady, + highlightedTags = [], + dimMode = false, + beaconPosition = null, }: FacilitySceneProps) { const controlsRef = useRef(null); const [keysPressed, setKeysPressed] = useState>({}); @@ -82,10 +89,15 @@ export function FacilityScene({ room={room} visMode={visMode} onPlantClick={onPlantClick} + highlightedTags={highlightedTags} + dimMode={dimMode} /> ))} + {/* Beacon for selected/searched plant */} + {beaconPosition && } + void; + onHighlightResults: (tagNumbers: string[]) => void; +} + +export function PlantSearch({ floorData, onSelectResult, onHighlightResults }: PlantSearchProps) { + const [query, setQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + // Build searchable index from floor data + const searchIndex = useMemo(() => { + const results: SearchResult[] = []; + + for (const room of floorData.rooms) { + for (const section of room.sections) { + for (const pos of section.positions) { + if (pos.plant) { + results.push({ + type: 'plant', + id: pos.plant.id, + label: pos.plant.tagNumber, + sublabel: `${pos.plant.strain || 'Unknown'} • ${pos.plant.stage || 'N/A'}`, + roomName: room.name, + position: { + roomX: room.posX, + roomY: room.posY, + sectionX: section.posX, + sectionY: section.posY, + row: pos.row, + column: pos.column, + tier: pos.tier, + }, + plant: pos, + }); + } + } + } + } + + return results; + }, [floorData]); + + // Fuzzy search filter + const filteredResults = useMemo(() => { + if (!query.trim()) return []; + + const q = query.toLowerCase(); + return searchIndex + .filter(r => + r.label.toLowerCase().includes(q) || + r.sublabel.toLowerCase().includes(q) || + r.roomName.toLowerCase().includes(q) + ) + .slice(0, 10); // Limit to 10 results + }, [query, searchIndex]); + + // Update highlights when results change + useEffect(() => { + if (filteredResults.length > 0) { + onHighlightResults(filteredResults.map(r => r.label)); + } else { + onHighlightResults([]); + } + }, [filteredResults, onHighlightResults]); + + const handleSelect = useCallback((result: SearchResult) => { + onSelectResult(result); + setQuery(''); + setIsOpen(false); + }, [onSelectResult]); + + const handleClear = useCallback(() => { + setQuery(''); + onHighlightResults([]); + }, [onHighlightResults]); + + return ( +
+ {/* Search Input */} +
+ + { + setQuery(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + placeholder="Search plants, batches..." + className="bg-transparent text-white text-sm placeholder:text-slate-500 outline-none flex-1 w-36" + /> + {query && ( + + )} +
+ + {/* Results Dropdown */} + {isOpen && filteredResults.length > 0 && ( +
+ {filteredResults.map((result) => ( + + ))} +
+ )} + + {/* No results message */} + {isOpen && query && filteredResults.length === 0 && ( +
+

No plants found

+
+ )} +
+ ); +} diff --git a/frontend/src/components/facility3d/PlantSystem.tsx b/frontend/src/components/facility3d/PlantSystem.tsx index 165fa56..473954a 100644 --- a/frontend/src/components/facility3d/PlantSystem.tsx +++ b/frontend/src/components/facility3d/PlantSystem.tsx @@ -21,9 +21,17 @@ interface PlantSystemProps { positions: PlantPosition[]; visMode: VisMode; onPlantClick: (plant: PlantPosition) => void; + highlightedTags?: string[]; // Tags to highlight + dimMode?: boolean; // Whether to dim non-highlighted plants } -export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemProps) { +export function PlantSystem({ + positions, + visMode, + onPlantClick, + highlightedTags = [], + dimMode = false +}: PlantSystemProps) { if (!positions || positions.length === 0) return null; const plants = positions.filter(p => p.plant); @@ -31,24 +39,44 @@ export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemPro if (plants.length > 5000) return null; - const plantColors = useMemo(() => { + const highlightSet = useMemo(() => new Set(highlightedTags), [highlightedTags]); + const hasHighlights = highlightedTags.length > 0; + + const plantData = useMemo(() => { return plants.map(pos => { + const isHighlighted = pos.plant && highlightSet.has(pos.plant.tagNumber); + const shouldDim = dimMode && hasHighlights && !isHighlighted; + + let color: string; switch (visMode) { case 'STANDARD': - return pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER : + 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); - return status === 'CRITICAL' ? COLORS.HEALTH_CRIT : + color = status === 'CRITICAL' ? COLORS.HEALTH_CRIT : status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD; + break; case 'YIELD': - return lerpColor('#86efac', '#14532d', Math.random()); + color = lerpColor('#86efac', '#14532d', Math.random()); + break; default: - return '#555'; + color = '#555'; } + + // Apply dim effect + if (shouldDim) { + color = '#3f3f46'; // Zinc-700 - muted gray + } + + // Apply highlight boost + const scale = isHighlighted ? 2.0 : (shouldDim ? 1.0 : 1.5); + + return { pos, color, scale, isHighlighted }; }); - }, [plants, visMode]); + }, [plants, visMode, highlightSet, dimMode, hasHighlights]); return ( @@ -65,18 +93,19 @@ export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemPro > - {plants.map((pos, i) => ( + {plantData.map(({ pos, color, scale }, i) => ( ))} )} - {emptySlots.length > 0 && visMode === 'STANDARD' && ( + {/* Empty slots - only show in STANDARD mode without dim */} + {emptySlots.length > 0 && visMode === 'STANDARD' && !dimMode && ( diff --git a/frontend/src/components/facility3d/RoomObject.tsx b/frontend/src/components/facility3d/RoomObject.tsx index 309a5a8..2695958 100644 --- a/frontend/src/components/facility3d/RoomObject.tsx +++ b/frontend/src/components/facility3d/RoomObject.tsx @@ -23,9 +23,11 @@ interface RoomObjectProps { room: Room3D; visMode: VisMode; onPlantClick: (plant: PlantPosition) => void; + highlightedTags?: string[]; + dimMode?: boolean; } -export function RoomObject({ room, visMode, onPlantClick }: RoomObjectProps) { +export function RoomObject({ room, visMode, onPlantClick, highlightedTags, dimMode }: RoomObjectProps) { const env = getMockRoomEnv(room.name); let floorColor: string = COLORS.ROOM_FLOOR; @@ -91,6 +93,8 @@ export function RoomObject({ room, visMode, onPlantClick }: RoomObjectProps) { section={section} visMode={visMode} onPlantClick={onPlantClick} + highlightedTags={highlightedTags} + dimMode={dimMode} /> ))} diff --git a/frontend/src/components/facility3d/SmartRack.tsx b/frontend/src/components/facility3d/SmartRack.tsx index fb72b02..8818b8a 100644 --- a/frontend/src/components/facility3d/SmartRack.tsx +++ b/frontend/src/components/facility3d/SmartRack.tsx @@ -9,9 +9,11 @@ interface SmartRackProps { section: Section3D; visMode: VisMode; onPlantClick: (plant: PlantPosition) => void; + highlightedTags?: string[]; + dimMode?: boolean; } -export function SmartRack({ section, visMode, onPlantClick }: SmartRackProps) { +export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dimMode }: SmartRackProps) { const positions: PlantPosition[] = useMemo(() => { const spacing = 0.5; return section.positions.map((pos: Position3D) => ({ @@ -107,7 +109,13 @@ export function SmartRack({ section, visMode, onPlantClick }: SmartRackProps) { ))} - + ); } diff --git a/frontend/src/components/facility3d/TimelineSlider.tsx b/frontend/src/components/facility3d/TimelineSlider.tsx new file mode 100644 index 0000000..a5b00a6 --- /dev/null +++ b/frontend/src/components/facility3d/TimelineSlider.tsx @@ -0,0 +1,122 @@ +import { useState, useCallback } from 'react'; +import { Play, Pause, SkipBack, SkipForward, Clock } from 'lucide-react'; + +interface TimelineSliderProps { + onDateChange: (date: Date) => void; + minDate?: Date; + maxDate?: Date; + isPlaying?: boolean; + onPlayPause?: () => void; +} + +export function TimelineSlider({ + onDateChange, + minDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago + maxDate = new Date(), + isPlaying = false, + onPlayPause, +}: TimelineSliderProps) { + const [value, setValue] = useState(100); // 0-100 representing the range + + const handleSliderChange = useCallback((e: React.ChangeEvent) => { + const newValue = parseInt(e.target.value, 10); + setValue(newValue); + + // Calculate date from slider position + const range = maxDate.getTime() - minDate.getTime(); + const timestamp = minDate.getTime() + (range * newValue / 100); + onDateChange(new Date(timestamp)); + }, [minDate, maxDate, onDateChange]); + + const jumpToStart = useCallback(() => { + setValue(0); + onDateChange(minDate); + }, [minDate, onDateChange]); + + const jumpToEnd = useCallback(() => { + setValue(100); + onDateChange(maxDate); + }, [maxDate, onDateChange]); + + // Calculate current date for display + const range = maxDate.getTime() - minDate.getTime(); + const currentDate = new Date(minDate.getTime() + (range * value / 100)); + const isLive = value === 100; + + return ( +
+
+ {/* Time Icon */} +
+ + Time Machine +
+ + {/* Controls */} +
+ + + + + +
+ + {/* Slider */} +
+ + + {/* Date markers */} +
+ {minDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + {maxDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+
+ + {/* Current Date Display */} +
+
+ {currentDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} +
+
+ {isLive ? ( + + + LIVE + + ) : ( + currentDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/facility3d/index.ts b/frontend/src/components/facility3d/index.ts index a36f7cc..f3c8475 100644 --- a/frontend/src/components/facility3d/index.ts +++ b/frontend/src/components/facility3d/index.ts @@ -2,4 +2,7 @@ export { FacilityScene } from './FacilityScene'; export { RoomObject } from './RoomObject'; export { SmartRack } from './SmartRack'; export { PlantSystem } from './PlantSystem'; +export { PlantSearch } from './PlantSearch'; +export { Beacon } from './Beacon'; +export { TimelineSlider } from './TimelineSlider'; export * from './types'; diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index 804bf12..ba5f3a4 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -1,10 +1,12 @@ -import { useEffect, useState, Suspense, Component, ReactNode } from 'react'; +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 } from 'lucide-react'; +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 { TimelineSlider } from '../components/facility3d/TimelineSlider'; import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types'; // --- Error Boundary --- @@ -39,6 +41,16 @@ export default function Facility3DViewerPage() { const [_controls, setControls] = useState(null); const [visMode, setVisMode] = useState('STANDARD'); + // Phase 2: Search state + const [highlightedTags, setHighlightedTags] = useState([]); + 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(new Date()); + const [isTimelinePlaying, setIsTimelinePlaying] = useState(false); + const [searchParams] = useSearchParams(); const targetedPlantTag = searchParams.get('plant'); @@ -46,6 +58,7 @@ export default function Facility3DViewerPage() { loadData(); }, []); + // Deep link handler useEffect(() => { if (floorData && targetedPlantTag) { for (const room of floorData.rooms) { @@ -53,14 +66,16 @@ export default function Facility3DViewerPage() { const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag); if (match) { const spacing = 0.5; - const plant: PlantPosition = { - ...match, - x: room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2, - z: room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2, - y: 0.4 + (match.tier * 0.6), - }; + const x = room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2; + const z = room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2; + const y = 0.4 + (match.tier * 0.6); + + const plant: PlantPosition = { ...match, x, y, z }; setSelectedPlant(plant); - setTargetView({ x: plant.x, y: 0, z: plant.z, zoom: true }); + setTargetView({ x, y: 0, z, zoom: true }); + setBeaconPosition([x, 0, z]); + setHighlightedTags([targetedPlantTag]); + setDimMode(true); return; } } @@ -96,9 +111,46 @@ export default function Facility3DViewerPage() { z: room.posY + room.height / 2 + offsetZ, zoom: true, }); + // Clear search state when navigating by room + setBeaconPosition(null); + setHighlightedTags([]); + setDimMode(false); }; - const resetView = () => setTargetView({ x: 0, y: 0, z: 0, zoom: false }); + const resetView = () => { + setTargetView({ x: 0, y: 0, z: 0, zoom: false }); + setBeaconPosition(null); + setHighlightedTags([]); + setDimMode(false); + setSelectedPlant(null); + }; + + // Search result selection handler + const handleSearchSelect = useCallback((result: any) => { + if (!floorData) return; + + const spacing = 0.5; + const x = result.position.roomX + result.position.sectionX + (result.position.column * spacing) - floorData.floor.width / 2; + const z = result.position.roomY + result.position.sectionY + (result.position.row * spacing) - floorData.floor.height / 2; + const y = 0.4 + (result.position.tier * 0.6); + + setTargetView({ x, y: 0, z, zoom: true }); + setBeaconPosition([x, y, z]); + setHighlightedTags([result.label]); + setDimMode(true); + setSelectedPlant({ ...result.plant, x, y, z }); + }, [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 (
@@ -119,6 +171,17 @@ export default function Facility3DViewerPage() {
+ {/* Search Bar */} + {floorData && ( +
+ +
+ )} + {/* Legend */}
{visMode === 'STANDARD' && ( @@ -175,6 +238,19 @@ export default function Facility3DViewerPage() { ))}
+ + {/* Focus Mode Toggle */} + {highlightedTags.length > 0 && ( +
+ +
+ )} )} @@ -191,8 +267,8 @@ export default function Facility3DViewerPage() { 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' + ? `${active} border-white/30 text-white scale-105` + : 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70' }`} title={label} > @@ -200,6 +276,21 @@ export default function Facility3DViewerPage() { {label} ))} + + {/* Timeline Toggle */} +
+ +
{/* Loading/Error Overlay */} @@ -227,7 +318,7 @@ export default function Facility3DViewerPage() {

{selectedPlant.plant.tagNumber}

- +
@@ -267,11 +358,25 @@ export default function Facility3DViewerPage() { targetView={targetView} onPlantClick={setSelectedPlant} onControlsReady={setControls} + highlightedTags={highlightedTags} + dimMode={dimMode} + beaconPosition={beaconPosition} /> )} + + {/* Timeline Slider - Phase 3 */} + {showTimeline && ( +
+ setIsTimelinePlaying(!isTimelinePlaying)} + /> +
+ )}
); }