fix: comprehensive UX improvements for 3D viewer
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

- 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
This commit is contained in:
fullsizemalt 2025-12-19 10:25:17 -08:00
parent 5f65997412
commit 6a398a2016
5 changed files with 284 additions and 45 deletions

View file

@ -8,53 +8,87 @@ interface BeaconProps {
height?: number; height?: number;
} }
export function Beacon({ position, color = '#22d3ee', height = 15 }: BeaconProps) { export function Beacon({ position, color = '#00ffff', height = 30 }: BeaconProps) {
const meshRef = useRef<THREE.Mesh>(null); const beamRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null); const ringRef = useRef<THREE.Mesh>(null);
const pulseRef = useRef<THREE.Mesh>(null);
// Animate the beacon // Animate the beacon
useFrame((state) => { useFrame((state) => {
if (meshRef.current) { const elapsed = state.clock.elapsedTime;
// Pulse opacity
const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.3 + 0.5; // Pulsing vertical beam
(meshRef.current.material as THREE.MeshBasicMaterial).opacity = pulse; 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) { if (ringRef.current) {
// Expand and fade ring const t = (elapsed % 1.5) / 1.5;
const t = (state.clock.elapsedTime % 2) / 2; ringRef.current.scale.set(1 + t * 4, 1, 1 + t * 4);
ringRef.current.scale.set(1 + t * 3, 1, 1 + t * 3); (ringRef.current.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - t);
(ringRef.current.material as THREE.MeshBasicMaterial).opacity = 0.6 * (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 ( return (
<group position={position}> <group position={position}>
{/* Vertical Light Beam */} {/* Bright vertical beam - more visible */}
<mesh ref={meshRef} position={[0, height / 2, 0]}> <mesh ref={beamRef} position={[0, height / 2, 0]}>
<cylinderGeometry args={[0.15, 0.4, height, 16, 1, true]} /> <cylinderGeometry args={[0.3, 0.8, height, 16, 1, true]} />
<meshBasicMaterial <meshBasicMaterial
color={color} color={color}
transparent transparent
opacity={0.5} opacity={0.7}
side={THREE.DoubleSide} side={THREE.DoubleSide}
depthWrite={false} depthWrite={false}
blending={THREE.AdditiveBlending}
/> />
</mesh> </mesh>
{/* Ground Ring */} {/* Top cap glow */}
<mesh ref={ringRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.05, 0]}> <mesh position={[0, height, 0]}>
<ringGeometry args={[0.3, 0.5, 32]} /> <sphereGeometry args={[0.6, 16, 16]} />
<meshBasicMaterial <meshBasicMaterial
color={color} color={color}
transparent transparent
opacity={0.6} opacity={0.9}
side={THREE.DoubleSide} blending={THREE.AdditiveBlending}
depthWrite={false}
/> />
</mesh> </mesh>
{/* Core Glow */} {/* Expanding ground ring */}
<pointLight color={color} intensity={2} distance={5} /> <mesh ref={ringRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.1, 0]}>
<ringGeometry args={[0.5, 1.0, 32]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.8}
side={THREE.DoubleSide}
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</mesh>
{/* Center pulsing sphere */}
<mesh ref={pulseRef} position={[0, 0.5, 0]}>
<sphereGeometry args={[0.4, 16, 16]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.9}
blending={THREE.AdditiveBlending}
/>
</mesh>
{/* Bright point light */}
<pointLight color={color} intensity={5} distance={20} />
</group> </group>
); );
} }

View file

@ -41,11 +41,13 @@ const getPresetConfig = (
}; };
case 'ROOM_FOCUS': 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 fx = focusTarget?.x ?? centerX;
const fz = focusTarget?.z ?? centerZ; 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 { 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], target: [fx, 0, fz] as [number, number, number],
}; };

View file

@ -16,39 +16,45 @@ interface HierarchyBreadcrumbProps {
} }
const levelIcons: Record<string, React.ReactNode> = { const levelIcons: Record<string, React.ReactNode> = {
facility: <Building2 size={14} />, facility: <Building2 size={12} />,
building: <Building2 size={14} />, building: <Building2 size={12} />,
floor: <Layers size={14} />, floor: <Layers size={12} />,
room: <LayoutGrid size={14} />, room: <LayoutGrid size={12} />,
section: <Grid3X3 size={14} />, section: <Grid3X3 size={12} />,
tier: <Box size={14} />, tier: <Box size={12} />,
};
// 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) { export function HierarchyBreadcrumb({ data, onNavigate }: HierarchyBreadcrumbProps) {
const allLevels: { key: keyof BreadcrumbData; label: string }[] = [ const allLevels: { key: keyof BreadcrumbData; label: string; displayLabel: string }[] = [
{ key: 'facility', label: data.facility || '' }, { key: 'facility', label: data.facility || '', displayLabel: truncate(data.facility || '', 12) },
{ key: 'building', label: data.building || '' }, { key: 'building', label: data.building || '', displayLabel: truncate(data.building || '', 12) },
{ key: 'floor', label: data.floor || '' }, { key: 'floor', label: data.floor || '', displayLabel: truncate(data.floor || '', 10) },
{ key: 'room', label: data.room || '' }, { key: 'room', label: data.room || '', displayLabel: data.room || '' },
{ key: 'section', label: data.section || '' }, { key: 'section', label: data.section || '', displayLabel: data.section || '' },
{ key: 'tier', label: data.tier ? `Tier ${data.tier}` : '' }, { 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; if (levels.length === 0) return null;
return ( return (
<div className="flex items-center gap-1 text-xs bg-black/60 px-3 py-1.5 rounded-lg backdrop-blur border border-white/10"> <div className="flex items-center gap-1 text-[10px] bg-black/60 px-2 py-1 rounded-md backdrop-blur border border-white/10 overflow-hidden max-w-full">
{levels.map((level, i) => ( {levels.map((level, i) => (
<div key={level.key} className="flex items-center gap-1"> <div key={level.key} className="flex items-center gap-0.5 shrink-0">
{i > 0 && <ChevronRight size={12} className="text-slate-500" />} {i > 0 && <ChevronRight size={10} className="text-slate-600 shrink-0" />}
<button <button
onClick={() => onNavigate?.(level.key)} onClick={() => onNavigate?.(level.key)}
className="flex items-center gap-1 hover:text-cyan-400 transition-colors" className="flex items-center gap-0.5 hover:text-cyan-400 transition-colors whitespace-nowrap"
title={`Navigate to ${level.key}`} title={level.label}
> >
<span className="text-slate-400">{levelIcons[level.key]}</span> <span className="text-slate-500">{levelIcons[level.key]}</span>
<span className="text-slate-200">{level.label}</span> <span className="text-slate-300">{level.displayLabel}</span>
</button> </button>
</div> </div>
))} ))}

View file

@ -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<GroupBy>('room');
const [expanded, setExpanded] = useState<Set<string>>(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<string, typeof allPlants> = {};
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 (
<div className="bg-slate-900/95 backdrop-blur border border-slate-700 rounded-lg overflow-hidden">
{/* Header with group toggle */}
<div className="p-3 border-b border-slate-700 flex items-center justify-between">
<span className="text-sm font-medium text-white flex items-center gap-2">
<Leaf size={14} className="text-green-400" />
Plants ({allPlants.length})
</span>
<div className="flex gap-1">
<button
onClick={() => setGroupBy('room')}
className={`text-xs px-2 py-1 rounded ${groupBy === 'room' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-700'}`}
title="Group by Room"
>
<Layers size={12} />
</button>
<button
onClick={() => setGroupBy('strain')}
className={`text-xs px-2 py-1 rounded ${groupBy === 'strain' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-700'}`}
title="Group by Strain"
>
🌿
</button>
<button
onClick={() => setGroupBy('stage')}
className={`text-xs px-2 py-1 rounded ${groupBy === 'stage' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-700'}`}
title="Group by Stage"
>
<Users size={12} />
</button>
</div>
</div>
{/* Groups list */}
<div className="max-h-[400px] overflow-y-auto">
{grouped.map(([groupName, plants]) => (
<div key={groupName} className="border-b border-slate-800 last:border-0">
{/* Group header */}
<button
onClick={() => toggleExpand(groupName)}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-slate-800 transition-colors"
>
<span className="flex items-center gap-2 text-sm">
{expanded.has(groupName) ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className={groupBy === 'stage' ? stageColor(groupName) : 'text-slate-200'}>
{groupName}
</span>
</span>
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">
{plants.length}
</span>
</button>
{/* Expanded plant list */}
{expanded.has(groupName) && (
<div className="bg-black/30 px-2 pb-2">
{plants.slice(0, 20).map((item) => (
<button
key={item.plant.id}
onClick={() => onPlantSelect(item.plant, item.roomName, item.sectionCode)}
className="w-full text-left px-2 py-1.5 hover:bg-slate-700 rounded text-xs flex justify-between items-center"
>
<span className="text-cyan-400 font-mono truncate max-w-[120px]">
{item.plant.plant?.tagNumber}
</span>
<span className={`${stageColor(item.stage)} text-[10px]`}>
{item.stage.substring(0, 4)}
</span>
</button>
))}
{plants.length > 20 && (
<div className="text-center text-[10px] text-slate-500 py-1">
+{plants.length - 20} more
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}

View file

@ -6,6 +6,7 @@ import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Dropl
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 { PlantSearch } from '../components/facility3d/PlantSearch';
import { PlantLibrary } from '../components/facility3d/PlantLibrary';
import { TimelineSlider } from '../components/facility3d/TimelineSlider'; import { TimelineSlider } from '../components/facility3d/TimelineSlider';
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types'; import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets'; import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets';
@ -351,6 +352,40 @@ export default function Facility3DViewerPage() {
</button> </button>
</div> </div>
)} )}
{/* Plant Library Browser */}
<div className="mt-2">
<PlantLibrary
floorData={floorData}
onPlantSelect={(plant, roomName, sectionCode) => {
// 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,
},
});
}
}}
/>
</div>
</div> </div>
)} )}