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
This commit is contained in:
parent
5f65997412
commit
6a398a2016
5 changed files with 284 additions and 45 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
162
frontend/src/components/facility3d/PlantLibrary.tsx
Normal file
162
frontend/src/components/facility3d/PlantLibrary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue