feat: add plant search, beacon highlights, and timeline slider (phases 2+3)
This commit is contained in:
parent
0758c4ffe6
commit
4d900d7bec
9 changed files with 521 additions and 27 deletions
60
frontend/src/components/facility3d/Beacon.tsx
Normal file
60
frontend/src/components/facility3d/Beacon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<CameraControls>(null);
|
||||
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -82,10 +89,15 @@ export function FacilityScene({
|
|||
room={room}
|
||||
visMode={visMode}
|
||||
onPlantClick={onPlantClick}
|
||||
highlightedTags={highlightedTags}
|
||||
dimMode={dimMode}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* Beacon for selected/searched plant */}
|
||||
{beaconPosition && <Beacon position={beaconPosition} />}
|
||||
|
||||
<ContactShadows
|
||||
position={[0, -0.02, 0]}
|
||||
opacity={0.35}
|
||||
|
|
|
|||
151
frontend/src/components/facility3d/PlantSearch.tsx
Normal file
151
frontend/src/components/facility3d/PlantSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<group>
|
||||
|
|
@ -65,18 +93,19 @@ export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemPro
|
|||
>
|
||||
<sphereGeometry args={[0.2, 12, 8]} />
|
||||
<meshStandardMaterial roughness={0.5} />
|
||||
{plants.map((pos, i) => (
|
||||
{plantData.map(({ pos, color, scale }, i) => (
|
||||
<Instance
|
||||
key={pos.id || i}
|
||||
position={[pos.x, pos.y + 0.25, pos.z]}
|
||||
color={plantColors[i]}
|
||||
scale={1.5}
|
||||
color={color}
|
||||
scale={scale}
|
||||
/>
|
||||
))}
|
||||
</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}>
|
||||
<cylinderGeometry args={[0.04, 0.04, 0.05, 6]} />
|
||||
<meshStandardMaterial color={COLORS.EMPTY_SLOT} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</Text>
|
||||
))}
|
||||
|
||||
<PlantSystem positions={positions} visMode={visMode} onPlantClick={onPlantClick} />
|
||||
<PlantSystem
|
||||
positions={positions}
|
||||
visMode={visMode}
|
||||
onPlantClick={onPlantClick}
|
||||
highlightedTags={highlightedTags}
|
||||
dimMode={dimMode}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
122
frontend/src/components/facility3d/TimelineSlider.tsx
Normal file
122
frontend/src/components/facility3d/TimelineSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<CameraControls | 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);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
{/* Search Bar */}
|
||||
{floorData && (
|
||||
<div className="pointer-events-auto">
|
||||
<PlantSearch
|
||||
floorData={floorData}
|
||||
onSelectResult={handleSearchSelect}
|
||||
onHighlightResults={handleHighlightResults}
|
||||
/>
|
||||
</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' && (
|
||||
|
|
@ -175,6 +238,19 @@ export default function Facility3DViewerPage() {
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -200,6 +276,21 @@ export default function Facility3DViewerPage() {
|
|||
<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 */}
|
||||
|
|
@ -227,7 +318,7 @@ export default function Facility3DViewerPage() {
|
|||
</span>
|
||||
<h3 className="font-bold text-accent text-lg leading-tight">{selectedPlant.plant.tagNumber}</h3>
|
||||
</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 className="grid grid-cols-2 gap-3 text-sm">
|
||||
|
|
@ -267,11 +358,25 @@ export default function Facility3DViewerPage() {
|
|||
targetView={targetView}
|
||||
onPlantClick={setSelectedPlant}
|
||||
onControlsReady={setControls}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue