feat: add plant search, beacon highlights, and timeline slider (phases 2+3)
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

This commit is contained in:
fullsizemalt 2025-12-18 00:19:21 -08:00
parent 0758c4ffe6
commit 4d900d7bec
9 changed files with 521 additions and 27 deletions

View file

@ -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<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(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 (
<group position={position}>
{/* Vertical Light Beam */}
<mesh ref={meshRef} position={[0, height / 2, 0]}>
<cylinderGeometry args={[0.15, 0.4, height, 16, 1, true]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.5}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
{/* Ground Ring */}
<mesh ref={ringRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.05, 0]}>
<ringGeometry args={[0.3, 0.5, 32]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.6}
side={THREE.DoubleSide}
depthWrite={false}
/>
</mesh>
{/* Core Glow */}
<pointLight color={color} intensity={2} distance={5} />
</group>
);
}

View file

@ -3,6 +3,7 @@ import { useFrame } from '@react-three/fiber';
import { CameraControls, Environment, ContactShadows } from '@react-three/drei'; import { CameraControls, Environment, ContactShadows } from '@react-three/drei';
import type { Floor3DData, Room3D } from '../../lib/layoutApi'; import type { Floor3DData, Room3D } from '../../lib/layoutApi';
import { RoomObject } from './RoomObject'; import { RoomObject } from './RoomObject';
import { Beacon } from './Beacon';
import { PlantPosition, VisMode } from './types'; import { PlantPosition, VisMode } from './types';
interface FacilitySceneProps { interface FacilitySceneProps {
@ -11,6 +12,9 @@ interface FacilitySceneProps {
targetView: { x: number; y: number; z: number; zoom?: boolean } | null; targetView: { x: number; y: number; z: number; zoom?: boolean } | null;
onPlantClick: (plant: PlantPosition) => void; onPlantClick: (plant: PlantPosition) => void;
onControlsReady: (controls: CameraControls) => void; onControlsReady: (controls: CameraControls) => void;
highlightedTags?: string[];
dimMode?: boolean;
beaconPosition?: [number, number, number] | null;
} }
export function FacilityScene({ export function FacilityScene({
@ -19,6 +23,9 @@ export function FacilityScene({
targetView, targetView,
onPlantClick, onPlantClick,
onControlsReady, onControlsReady,
highlightedTags = [],
dimMode = false,
beaconPosition = null,
}: FacilitySceneProps) { }: FacilitySceneProps) {
const controlsRef = useRef<CameraControls>(null); const controlsRef = useRef<CameraControls>(null);
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({}); const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
@ -82,10 +89,15 @@ export function FacilityScene({
room={room} room={room}
visMode={visMode} visMode={visMode}
onPlantClick={onPlantClick} onPlantClick={onPlantClick}
highlightedTags={highlightedTags}
dimMode={dimMode}
/> />
))} ))}
</group> </group>
{/* Beacon for selected/searched plant */}
{beaconPosition && <Beacon position={beaconPosition} />}
<ContactShadows <ContactShadows
position={[0, -0.02, 0]} position={[0, -0.02, 0]}
opacity={0.35} opacity={0.35}

View file

@ -0,0 +1,151 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Search, X, MapPin } from 'lucide-react';
import type { Floor3DData, Position3D } from '../../lib/layoutApi';
interface SearchResult {
type: 'plant' | 'batch' | 'strain';
id: string;
label: string;
sublabel: string;
roomName: string;
position: {
roomX: number;
roomY: number;
sectionX: number;
sectionY: number;
row: number;
column: number;
tier: number;
};
plant: Position3D;
}
interface PlantSearchProps {
floorData: Floor3DData;
onSelectResult: (result: SearchResult) => 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 (
<div className="relative">
{/* Search Input */}
<div className="flex items-center gap-2 bg-black/60 backdrop-blur rounded-lg border border-slate-700 px-3 py-2">
<Search size={16} className="text-slate-400" />
<input
type="text"
value={query}
onChange={(e) => {
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 && (
<button onClick={handleClear} className="text-slate-400 hover:text-white">
<X size={14} />
</button>
)}
</div>
{/* Results Dropdown */}
{isOpen && filteredResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-900/95 backdrop-blur border border-slate-700 rounded-lg shadow-xl max-h-64 overflow-y-auto z-50">
{filteredResults.map((result) => (
<button
key={result.id}
onClick={() => handleSelect(result)}
className="w-full text-left px-3 py-2 hover:bg-slate-800 transition-colors flex items-start gap-3 border-b border-slate-800 last:border-0"
>
<MapPin size={14} className="text-accent mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium truncate">{result.label}</div>
<div className="text-slate-400 text-xs truncate">{result.sublabel}</div>
<div className="text-slate-500 text-[10px] mt-0.5">{result.roomName}</div>
</div>
</button>
))}
</div>
)}
{/* No results message */}
{isOpen && query && filteredResults.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-900/95 backdrop-blur border border-slate-700 rounded-lg p-3 text-center">
<p className="text-slate-400 text-sm">No plants found</p>
</div>
)}
</div>
);
}

View file

@ -21,9 +21,17 @@ interface PlantSystemProps {
positions: PlantPosition[]; positions: PlantPosition[];
visMode: VisMode; visMode: VisMode;
onPlantClick: (plant: PlantPosition) => void; 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; if (!positions || positions.length === 0) return null;
const plants = positions.filter(p => p.plant); const plants = positions.filter(p => p.plant);
@ -31,24 +39,44 @@ export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemPro
if (plants.length > 5000) return null; 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 => { return plants.map(pos => {
const isHighlighted = pos.plant && highlightSet.has(pos.plant.tagNumber);
const shouldDim = dimMode && hasHighlights && !isHighlighted;
let color: string;
switch (visMode) { switch (visMode) {
case 'STANDARD': 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 === 'DRYING' ? COLORS.DRY :
pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG; pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG;
break;
case 'HEALTH': case 'HEALTH':
const status = getMockPlantHealth(pos.plant!.id); 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; status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD;
break;
case 'YIELD': case 'YIELD':
return lerpColor('#86efac', '#14532d', Math.random()); color = lerpColor('#86efac', '#14532d', Math.random());
break;
default: 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 ( return (
<group> <group>
@ -65,18 +93,19 @@ export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemPro
> >
<sphereGeometry args={[0.2, 12, 8]} /> <sphereGeometry args={[0.2, 12, 8]} />
<meshStandardMaterial roughness={0.5} /> <meshStandardMaterial roughness={0.5} />
{plants.map((pos, i) => ( {plantData.map(({ pos, color, scale }, i) => (
<Instance <Instance
key={pos.id || i} key={pos.id || i}
position={[pos.x, pos.y + 0.25, pos.z]} position={[pos.x, pos.y + 0.25, pos.z]}
color={plantColors[i]} color={color}
scale={1.5} scale={scale}
/> />
))} ))}
</Instances> </Instances>
)} )}
{emptySlots.length > 0 && visMode === 'STANDARD' && ( {/* Empty slots - only show in STANDARD mode without dim */}
{emptySlots.length > 0 && visMode === 'STANDARD' && !dimMode && (
<Instances range={emptySlots.length}> <Instances range={emptySlots.length}>
<cylinderGeometry args={[0.04, 0.04, 0.05, 6]} /> <cylinderGeometry args={[0.04, 0.04, 0.05, 6]} />
<meshStandardMaterial color={COLORS.EMPTY_SLOT} /> <meshStandardMaterial color={COLORS.EMPTY_SLOT} />

View file

@ -23,9 +23,11 @@ interface RoomObjectProps {
room: Room3D; room: Room3D;
visMode: VisMode; visMode: VisMode;
onPlantClick: (plant: PlantPosition) => void; 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); const env = getMockRoomEnv(room.name);
let floorColor: string = COLORS.ROOM_FLOOR; let floorColor: string = COLORS.ROOM_FLOOR;
@ -91,6 +93,8 @@ export function RoomObject({ room, visMode, onPlantClick }: RoomObjectProps) {
section={section} section={section}
visMode={visMode} visMode={visMode}
onPlantClick={onPlantClick} onPlantClick={onPlantClick}
highlightedTags={highlightedTags}
dimMode={dimMode}
/> />
))} ))}
</group> </group>

View file

@ -9,9 +9,11 @@ interface SmartRackProps {
section: Section3D; section: Section3D;
visMode: VisMode; visMode: VisMode;
onPlantClick: (plant: PlantPosition) => void; 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 positions: PlantPosition[] = useMemo(() => {
const spacing = 0.5; const spacing = 0.5;
return section.positions.map((pos: Position3D) => ({ return section.positions.map((pos: Position3D) => ({
@ -107,7 +109,13 @@ export function SmartRack({ section, visMode, onPlantClick }: SmartRackProps) {
</Text> </Text>
))} ))}
<PlantSystem positions={positions} visMode={visMode} onPlantClick={onPlantClick} /> <PlantSystem
positions={positions}
visMode={visMode}
onPlantClick={onPlantClick}
highlightedTags={highlightedTags}
dimMode={dimMode}
/>
</group> </group>
); );
} }

View file

@ -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<HTMLInputElement>) => {
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 (
<div className="bg-black/70 backdrop-blur-md rounded-xl border border-slate-700 p-4 shadow-2xl">
<div className="flex items-center gap-4">
{/* Time Icon */}
<div className="flex items-center gap-2 text-cyan-400">
<Clock size={18} />
<span className="text-xs font-semibold uppercase tracking-wider">Time Machine</span>
</div>
{/* Controls */}
<div className="flex items-center gap-1">
<button
onClick={jumpToStart}
className="p-1.5 rounded-lg hover:bg-slate-700 transition-colors text-slate-400 hover:text-white"
title="Go to start"
>
<SkipBack size={16} />
</button>
<button
onClick={onPlayPause}
className={`p-2 rounded-lg transition-colors ${isPlaying ? 'bg-cyan-600 text-white' : 'hover:bg-slate-700 text-slate-400 hover:text-white'}`}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button
onClick={jumpToEnd}
className="p-1.5 rounded-lg hover:bg-slate-700 transition-colors text-slate-400 hover:text-white"
title="Go to now"
>
<SkipForward size={16} />
</button>
</div>
{/* Slider */}
<div className="flex-1 relative">
<input
type="range"
min="0"
max="100"
value={value}
onChange={handleSliderChange}
className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-cyan-500"
style={{
background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${value}%, #334155 ${value}%, #334155 100%)`
}}
/>
{/* Date markers */}
<div className="absolute -bottom-5 left-0 right-0 flex justify-between text-[10px] text-slate-500">
<span>{minDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
<span>{maxDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
</div>
</div>
{/* Current Date Display */}
<div className={`min-w-[140px] text-right ${isLive ? 'text-green-400' : 'text-cyan-300'}`}>
<div className="text-sm font-mono">
{currentDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
</div>
<div className="text-xs text-slate-400">
{isLive ? (
<span className="flex items-center justify-end gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
LIVE
</span>
) : (
currentDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -2,4 +2,7 @@ export { FacilityScene } from './FacilityScene';
export { RoomObject } from './RoomObject'; export { RoomObject } from './RoomObject';
export { SmartRack } from './SmartRack'; export { SmartRack } from './SmartRack';
export { PlantSystem } from './PlantSystem'; export { PlantSystem } from './PlantSystem';
export { PlantSearch } from './PlantSearch';
export { Beacon } from './Beacon';
export { TimelineSlider } from './TimelineSlider';
export * from './types'; export * from './types';

View file

@ -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 { Canvas } from '@react-three/fiber';
import { Html, CameraControls } from '@react-three/drei'; import { Html, CameraControls } from '@react-three/drei';
import { layoutApi, Floor3DData, Room3D, Position3D } from '../lib/layoutApi'; 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 { Link, useSearchParams } from 'react-router-dom';
import { FacilityScene } from '../components/facility3d/FacilityScene'; 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'; import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
// --- Error Boundary --- // --- Error Boundary ---
@ -39,6 +41,16 @@ export default function Facility3DViewerPage() {
const [_controls, setControls] = useState<CameraControls | null>(null); const [_controls, setControls] = useState<CameraControls | null>(null);
const [visMode, setVisMode] = useState<VisMode>('STANDARD'); 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);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const targetedPlantTag = searchParams.get('plant'); const targetedPlantTag = searchParams.get('plant');
@ -46,6 +58,7 @@ export default function Facility3DViewerPage() {
loadData(); loadData();
}, []); }, []);
// Deep link handler
useEffect(() => { useEffect(() => {
if (floorData && targetedPlantTag) { if (floorData && targetedPlantTag) {
for (const room of floorData.rooms) { 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); const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag);
if (match) { if (match) {
const spacing = 0.5; const spacing = 0.5;
const plant: PlantPosition = { const x = room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2;
...match, const z = room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2;
x: room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2, const y = 0.4 + (match.tier * 0.6);
z: room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2,
y: 0.4 + (match.tier * 0.6), const plant: PlantPosition = { ...match, x, y, z };
};
setSelectedPlant(plant); 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; return;
} }
} }
@ -96,9 +111,46 @@ export default function Facility3DViewerPage() {
z: room.posY + room.height / 2 + offsetZ, z: room.posY + room.height / 2 + offsetZ,
zoom: true, 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 ( return (
<div className="h-screen w-full relative bg-slate-950 text-white overflow-hidden font-sans"> <div className="h-screen w-full relative bg-slate-950 text-white overflow-hidden font-sans">
@ -119,6 +171,17 @@ export default function Facility3DViewerPage() {
</div> </div>
</div> </div>
{/* Search Bar */}
{floorData && (
<div className="pointer-events-auto">
<PlantSearch
floorData={floorData}
onSelectResult={handleSearchSelect}
onHighlightResults={handleHighlightResults}
/>
</div>
)}
{/* Legend */} {/* Legend */}
<div className="flex gap-3 text-xs bg-black/60 p-2 rounded-lg backdrop-blur border border-white/10 pointer-events-auto"> <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' && ( {visMode === 'STANDARD' && (
@ -175,6 +238,19 @@ export default function Facility3DViewerPage() {
))} ))}
</div> </div>
</div> </div>
{/* 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>
)}
</div> </div>
)} )}
@ -200,6 +276,21 @@ export default function Facility3DViewerPage() {
<span className="text-[9px] block text-center mt-0.5">{label}</span> <span className="text-[9px] block text-center mt-0.5">{label}</span>
</button> </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> </div>
{/* Loading/Error Overlay */} {/* Loading/Error Overlay */}
@ -227,7 +318,7 @@ export default function Facility3DViewerPage() {
</span> </span>
<h3 className="font-bold text-accent text-lg leading-tight">{selectedPlant.plant.tagNumber}</h3> <h3 className="font-bold text-accent text-lg leading-tight">{selectedPlant.plant.tagNumber}</h3>
</div> </div>
<button onClick={() => setSelectedPlant(null)} className="text-slate-400 hover:text-white text-lg">×</button> <button onClick={() => { setSelectedPlant(null); setBeaconPosition(null); }} className="text-slate-400 hover:text-white text-lg">×</button>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
@ -267,11 +358,25 @@ export default function Facility3DViewerPage() {
targetView={targetView} targetView={targetView}
onPlantClick={setSelectedPlant} onPlantClick={setSelectedPlant}
onControlsReady={setControls} onControlsReady={setControls}
highlightedTags={highlightedTags}
dimMode={dimMode}
beaconPosition={beaconPosition}
/> />
)} )}
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</Canvas> </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> </div>
); );
} }