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 { 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}
|
||||||
|
|
|
||||||
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[];
|
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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue