From 6a398a201681f49e510b68fbac69337132fbfc82 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:25:17 -0800 Subject: [PATCH] fix: comprehensive UX improvements for 3D viewer - Fix breadcrumb overflow with truncation and smaller text - Fix Room Focus camera positioning (proper isometric angle) - Make beacon more visible (taller, additive blending, pulsing) - Add PlantLibrary browser (browse by room/strain/stage) - Enable plant discovery without knowing search terms --- frontend/src/components/facility3d/Beacon.tsx | 78 ++++++--- .../components/facility3d/CameraPresets.tsx | 6 +- .../facility3d/HierarchyBreadcrumb.tsx | 48 +++--- .../components/facility3d/PlantLibrary.tsx | 162 ++++++++++++++++++ frontend/src/pages/Facility3DViewerPage.tsx | 35 ++++ 5 files changed, 284 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/facility3d/PlantLibrary.tsx diff --git a/frontend/src/components/facility3d/Beacon.tsx b/frontend/src/components/facility3d/Beacon.tsx index cc33b72..cdb2a4c 100644 --- a/frontend/src/components/facility3d/Beacon.tsx +++ b/frontend/src/components/facility3d/Beacon.tsx @@ -8,53 +8,87 @@ interface BeaconProps { height?: number; } -export function Beacon({ position, color = '#22d3ee', height = 15 }: BeaconProps) { - const meshRef = useRef(null); +export function Beacon({ position, color = '#00ffff', height = 30 }: BeaconProps) { + const beamRef = useRef(null); const ringRef = useRef(null); + const pulseRef = 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; + const elapsed = state.clock.elapsedTime; + + // Pulsing vertical beam + if (beamRef.current) { + const pulse = Math.sin(elapsed * 4) * 0.2 + 0.7; + (beamRef.current.material as THREE.MeshBasicMaterial).opacity = pulse; } + + // Expanding ring 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); + const t = (elapsed % 1.5) / 1.5; + ringRef.current.scale.set(1 + t * 4, 1, 1 + t * 4); + (ringRef.current.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - t); + } + + // Pulsing center sphere + if (pulseRef.current) { + const scale = 1 + Math.sin(elapsed * 6) * 0.3; + pulseRef.current.scale.set(scale, scale, scale); } }); return ( - {/* Vertical Light Beam */} - - + {/* Bright vertical beam - more visible */} + + - {/* Ground Ring */} - - + {/* Top cap glow */} + + - {/* Core Glow */} - + {/* Expanding ground ring */} + + + + + + {/* Center pulsing sphere */} + + + + + + {/* Bright point light */} + ); } diff --git a/frontend/src/components/facility3d/CameraPresets.tsx b/frontend/src/components/facility3d/CameraPresets.tsx index 483b6e9..49c636d 100644 --- a/frontend/src/components/facility3d/CameraPresets.tsx +++ b/frontend/src/components/facility3d/CameraPresets.tsx @@ -41,11 +41,13 @@ const getPresetConfig = ( }; case 'ROOM_FOCUS': - // Focus on a specific room/area + // Focus on a specific room/area - isometric view centered on target const fx = focusTarget?.x ?? centerX; const fz = focusTarget?.z ?? centerZ; + // Position camera at 45° angle from target, closer in for room view + const roomViewDist = 25; // Closer than full-floor isometric return { - position: [fx + 15, 12, fz + 15] as [number, number, number], + position: [fx + roomViewDist * 0.7, roomViewDist * 0.5, fz + roomViewDist * 0.7] as [number, number, number], target: [fx, 0, fz] as [number, number, number], }; diff --git a/frontend/src/components/facility3d/HierarchyBreadcrumb.tsx b/frontend/src/components/facility3d/HierarchyBreadcrumb.tsx index 18a4560..6774ba3 100644 --- a/frontend/src/components/facility3d/HierarchyBreadcrumb.tsx +++ b/frontend/src/components/facility3d/HierarchyBreadcrumb.tsx @@ -16,39 +16,45 @@ interface HierarchyBreadcrumbProps { } const levelIcons: Record = { - facility: , - building: , - floor: , - room: , - section: , - tier: , + facility: , + building: , + floor: , + room: , + section: , + tier: , +}; + +// Truncate long names to fit in breadcrumb +const truncate = (str: string, maxLen: number) => { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + '…'; }; export function HierarchyBreadcrumb({ data, onNavigate }: HierarchyBreadcrumbProps) { - const allLevels: { key: keyof BreadcrumbData; label: string }[] = [ - { key: 'facility', label: data.facility || '' }, - { key: 'building', label: data.building || '' }, - { key: 'floor', label: data.floor || '' }, - { key: 'room', label: data.room || '' }, - { key: 'section', label: data.section || '' }, - { key: 'tier', label: data.tier ? `Tier ${data.tier}` : '' }, + const allLevels: { key: keyof BreadcrumbData; label: string; displayLabel: string }[] = [ + { key: 'facility', label: data.facility || '', displayLabel: truncate(data.facility || '', 12) }, + { key: 'building', label: data.building || '', displayLabel: truncate(data.building || '', 12) }, + { key: 'floor', label: data.floor || '', displayLabel: truncate(data.floor || '', 10) }, + { key: 'room', label: data.room || '', displayLabel: data.room || '' }, + { key: 'section', label: data.section || '', displayLabel: data.section || '' }, + { key: 'tier', label: data.tier ? `Tier ${data.tier}` : '', displayLabel: data.tier ? `T${data.tier}` : '' }, ]; - const levels = allLevels.filter((l): l is { key: keyof BreadcrumbData; label: string } => !!l.label); + const levels = allLevels.filter((l) => !!l.label); if (levels.length === 0) return null; return ( -
+
{levels.map((level, i) => ( -
- {i > 0 && } +
+ {i > 0 && }
))} diff --git a/frontend/src/components/facility3d/PlantLibrary.tsx b/frontend/src/components/facility3d/PlantLibrary.tsx new file mode 100644 index 0000000..159af8f --- /dev/null +++ b/frontend/src/components/facility3d/PlantLibrary.tsx @@ -0,0 +1,162 @@ +import { useState, useMemo } from 'react'; +import { ChevronDown, ChevronRight, Leaf, Users, Layers } from 'lucide-react'; +import type { Floor3DData, Position3D } from '../../lib/layoutApi'; + +interface PlantLibraryProps { + floorData: Floor3DData; + onPlantSelect: (plant: Position3D, roomName: string, sectionCode: string) => void; +} + +type GroupBy = 'room' | 'strain' | 'stage'; + +export function PlantLibrary({ floorData, onPlantSelect }: PlantLibraryProps) { + const [groupBy, setGroupBy] = useState('room'); + const [expanded, setExpanded] = useState>(new Set()); + + // Build plant list with metadata + const allPlants = useMemo(() => { + const plants: Array<{ + plant: Position3D; + roomName: string; + sectionCode: string; + strain: string; + stage: string; + }> = []; + + for (const room of floorData.rooms) { + for (const section of room.sections || []) { + for (const pos of section.positions || []) { + if (pos.plant) { + plants.push({ + plant: pos, + roomName: room.name, + sectionCode: section.code || section.name, + strain: pos.plant.strain || 'Unknown', + stage: pos.plant.stage || 'Unknown', + }); + } + } + } + } + return plants; + }, [floorData]); + + // Group plants by selected criteria + const grouped = useMemo(() => { + const groups: Record = {}; + + for (const item of allPlants) { + const key = groupBy === 'room' ? item.roomName + : groupBy === 'strain' ? item.strain + : item.stage; + if (!groups[key]) groups[key] = []; + groups[key].push(item); + } + + // Sort groups by name, then by count + return Object.entries(groups) + .sort((a, b) => b[1].length - a[1].length); + }, [allPlants, groupBy]); + + const toggleExpand = (key: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const stageColor = (stage: string) => { + switch (stage.toUpperCase()) { + case 'VEGETATIVE': return 'text-green-400'; + case 'FLOWERING': return 'text-purple-400'; + case 'DRYING': return 'text-orange-400'; + case 'CURING': return 'text-amber-600'; + default: return 'text-slate-400'; + } + }; + + return ( +
+ {/* Header with group toggle */} +
+ + + Plants ({allPlants.length}) + +
+ + + +
+
+ + {/* Groups list */} +
+ {grouped.map(([groupName, plants]) => ( +
+ {/* Group header */} + + + {/* Expanded plant list */} + {expanded.has(groupName) && ( +
+ {plants.slice(0, 20).map((item) => ( + + ))} + {plants.length > 20 && ( +
+ +{plants.length - 20} more +
+ )} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index 7d7bbd6..1ec2434 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -6,6 +6,7 @@ import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Dropl import { Link, useSearchParams } from 'react-router-dom'; import { FacilityScene } from '../components/facility3d/FacilityScene'; import { PlantSearch } from '../components/facility3d/PlantSearch'; +import { PlantLibrary } from '../components/facility3d/PlantLibrary'; import { TimelineSlider } from '../components/facility3d/TimelineSlider'; import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types'; import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets'; @@ -351,6 +352,40 @@ export default function Facility3DViewerPage() {
)} + + {/* Plant Library Browser */} +
+ { + // Calculate position and select plant + const room = floorData.rooms.find(r => r.name === roomName); + const section = room?.sections.find(s => (s.code || s.name) === sectionCode); + if (room && section) { + const SCALE = 0.1; + const x = (room.posX + section.posX + (plant.column * 0.5)) * SCALE - (floorData.floor.width * SCALE / 2); + const z = (room.posY + section.posY + (plant.row * 0.5)) * SCALE - (floorData.floor.height * SCALE / 2); + const y = 0.4 + (plant.tier * 0.6); + + setFocusTarget({ x, z }); + setCameraPreset('ROOM_FOCUS'); + setBeaconPosition([x, y, z]); + setDimMode(false); + setHighlightedTags([plant.plant?.tagNumber || '']); + setSelectedPlant({ + ...plant, + x, + y, + z, + breadcrumb: { + room: roomName, + section: sectionCode, + }, + }); + } + }} + /> +
)}