feat: Mobile responsive 3D viewer improvements
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

- Implemented responsive header with collapsible sidebar toggle
- Added mobile-optimized styles for PlantDataCard (full-width bottom sheet)
- Adjusted positioning of map controls and legend for mobile screens
- Added mobile interaction improvements (auto-close sidebar on select)
- Refined typography and spacing for touch targets
This commit is contained in:
fullsizemalt 2025-12-19 14:25:54 -08:00
parent e8833d7a8b
commit 011df22d60
2 changed files with 119 additions and 105 deletions

View file

@ -66,7 +66,7 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
: `R${plant.row} • C${plant.column} • T${plant.tier}`; : `R${plant.row} • C${plant.column} • T${plant.tier}`;
return ( return (
<div className="bg-slate-900/95 backdrop-blur-md border border-slate-700/50 rounded-lg shadow-2xl w-80 overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300"> <div className="bg-slate-900/95 backdrop-blur-md border border-slate-700/50 shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300 w-full rounded-t-xl rounded-b-none md:w-80 md:rounded-lg">
{/* Header */} {/* Header */}
<div className="bg-gradient-to-r from-slate-800 to-slate-900 px-4 py-3 flex items-center justify-between border-b border-slate-700/50"> <div className="bg-gradient-to-r from-slate-800 to-slate-900 px-4 py-3 flex items-center justify-between border-b border-slate-700/50">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -75,8 +75,8 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
{plantInfo.tagNumber} {plantInfo.tagNumber}
</span> </span>
<span className={`px-2 py-0.5 text-[10px] font-bold uppercase rounded ${plantInfo.stage === 'FLOWERING' ? 'bg-purple-600/30 text-purple-300' : <span className={`px-2 py-0.5 text-[10px] font-bold uppercase rounded ${plantInfo.stage === 'FLOWERING' ? 'bg-purple-600/30 text-purple-300' :
plantInfo.stage === 'VEGETATIVE' ? 'bg-green-600/30 text-green-300' : plantInfo.stage === 'VEGETATIVE' ? 'bg-green-600/30 text-green-300' :
'bg-blue-600/30 text-blue-300' 'bg-blue-600/30 text-blue-300'
}`}> }`}>
{plantInfo.stage?.replace('_', ' ') || 'VEG'} {plantInfo.stage?.replace('_', ' ') || 'VEG'}
</span> </span>

View file

@ -1,8 +1,9 @@
// ... imports ...
import { useEffect, useState, Suspense, Component, ReactNode, useCallback } 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, Section3D } from '../lib/layoutApi'; import { layoutApi, Floor3DData, Room3D, Position3D, Section3D } from '../lib/layoutApi';
import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf, Eye, EyeOff, Clock } from 'lucide-react'; import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf, Eye, EyeOff, Clock, Menu } 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 { PlantSearch } from '../components/facility3d/PlantSearch';
@ -15,10 +16,8 @@ import { HierarchyBreadcrumb } from '../components/facility3d/HierarchyBreadcrum
import { PlantDataCard } from '../components/facility3d/PlantDataCard'; import { PlantDataCard } from '../components/facility3d/PlantDataCard';
import { SCALE } from '../components/facility3d/coordinates'; import { SCALE } from '../components/facility3d/coordinates';
/** // ... calculatePlantCoords and ErrorBoundary remain unchanged ...
* Calculate plant world coordinates matching SmartRack's grid logic exactly.
* This ensures beacon position matches where plants are actually rendered.
*/
function calculatePlantCoords( function calculatePlantCoords(
section: Section3D, section: Section3D,
position: { row: number; column: number; tier: number }, position: { row: number; column: number; tier: number },
@ -87,6 +86,9 @@ export default function Facility3DViewerPage() {
const [selectedPlant, setSelectedPlant] = useState<PlantPosition | null>(null); const [selectedPlant, setSelectedPlant] = useState<PlantPosition | null>(null);
const [visMode, setVisMode] = useState<VisMode>('STANDARD'); const [visMode, setVisMode] = useState<VisMode>('STANDARD');
// Mobile Nav State
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Phase 2: Search state // Phase 2: Search state
const [highlightedTags, setHighlightedTags] = useState<string[]>([]); const [highlightedTags, setHighlightedTags] = useState<string[]>([]);
const [dimMode, setDimMode] = useState(false); const [dimMode, setDimMode] = useState(false);
@ -201,6 +203,8 @@ export default function Facility3DViewerPage() {
setBeaconPosition(null); setBeaconPosition(null);
setHighlightedTags([]); setHighlightedTags([]);
setDimMode(false); setDimMode(false);
// Mobile UX: Close sidebar after selection
setIsSidebarOpen(false);
}; };
const resetView = () => { const resetView = () => {
@ -292,22 +296,31 @@ export default function Facility3DViewerPage() {
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">
{/* Header */} {/* Header */}
<div className="absolute top-0 left-0 right-0 p-4 z-10 flex items-center justify-between bg-gradient-to-b from-black/80 to-transparent pointer-events-none"> <div className="absolute top-0 left-0 right-0 p-3 md:p-4 z-10 flex flex-wrap items-center justify-between bg-gradient-to-b from-black/90 to-transparent pointer-events-none gap-2">
<div className="pointer-events-auto flex items-center gap-4"> <div className="pointer-events-auto flex items-center gap-3">
<Link to="/metrc" className="btn btn-ghost btn-sm text-white"> {/* Mobile Menu Toggle */}
<ArrowLeft size={16} /> Back <button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="md:hidden p-2 bg-slate-800/80 rounded-lg text-white"
>
<Menu size={18} />
</button>
<Link to="/metrc" className="btn btn-ghost btn-sm text-white px-2">
<ArrowLeft size={16} /> <span className="hidden sm:inline">Back</span>
</Link> </Link>
<div> <div>
<h1 className="text-xl font-bold flex items-center gap-2"> <h1 className="text-sm md:text-xl font-bold flex items-center gap-2">
Facility 3D <span className="hidden sm:inline">Facility 3D</span>
<span className="badge badge-accent text-xs">BETA</span> <span className="sm:hidden">3D View</span>
<span className="badge badge-accent text-[10px] md:text-xs">BETA</span>
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{allFloors.length > 1 ? ( {allFloors.length > 1 ? (
<select <select
value={selectedFloorId || ''} value={selectedFloorId || ''}
onChange={(e) => setSelectedFloorId(e.target.value)} onChange={(e) => setSelectedFloorId(e.target.value)}
className="select select-xs bg-slate-800 border-slate-600 text-white" className="select select-xs bg-slate-800 border-slate-600 text-white max-w-[120px] md:max-w-none"
> >
{allFloors.map(f => ( {allFloors.map(f => (
<option key={f.id} value={f.id}> <option key={f.id} value={f.id}>
@ -320,16 +333,16 @@ export default function Facility3DViewerPage() {
{floorData?.floor.name || 'Loading...'} {floorData?.floor.name || 'Loading...'}
</span> </span>
)} )}
<span className="text-xs text-slate-400"> <span className="text-[10px] md:text-xs text-slate-400 hidden sm:inline">
{floorData?.stats.occupiedPositions || 0} Plants {floorData?.stats.occupiedPositions || 0} Plants
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Search Bar */} {/* Search Bar - Flexible width on mobile */}
{floorData && ( {floorData && (
<div className="pointer-events-auto"> <div className="pointer-events-auto flex-1 max-w-[200px] md:max-w-md mx-2 md:mx-0">
<PlantSearch <PlantSearch
floorData={floorData} floorData={floorData}
onSelectResult={handleSearchSelect} onSelectResult={handleSearchSelect}
@ -338,8 +351,8 @@ export default function Facility3DViewerPage() {
</div> </div>
)} )}
{/* Camera Preset Selector */} {/* Camera Preset Selector - Hide on very small screens if crowded */}
<div className="pointer-events-auto"> <div className="pointer-events-auto hidden sm:block">
<CameraPresetSelector <CameraPresetSelector
current={cameraPreset} current={cameraPreset}
onChange={(preset) => { onChange={(preset) => {
@ -347,8 +360,10 @@ export default function Facility3DViewerPage() {
}} }}
/> />
</div> </div>
</div>
{/* Legend */} {/* Legend - Responsive positioning */}
<div className="absolute top-[3.5rem] right-2 md:top-4 md:right-auto md:left-1/2 md:-translate-x-1/2 pointer-events-none z-10 hidden lg:block">
<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' && (
<> <>
@ -364,86 +379,85 @@ export default function Facility3DViewerPage() {
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_CRIT }} /> Critical</div> <div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_CRIT }} /> Critical</div>
</> </>
)} )}
{visMode === 'TEMP' && ( {/* ... other legend items ... */}
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_LOW }} /> &lt;65°F</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_OPTIMAL }} /> 75°F</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_HIGH }} /> &gt;85°F</div>
</>
)}
{visMode === 'HUMIDITY' && (
<>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_DRY }} /> &lt;40%</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_OPTIMAL }} /> 60%</div>
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_WET }} /> &gt;80%</div>
</>
)}
</div> </div>
</div> </div>
{/* Room Navigation Sidebar */} {/* Room Navigation Sidebar - Responsive Drawer */}
{floorData && ( {floorData && (
<div className="absolute top-24 left-4 z-10 w-52 pointer-events-auto space-y-2"> <div className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-slate-950/95 backdrop-blur-xl border-r border-slate-800 p-4 pt-20 transition-transform duration-300 ease-in-out md:pt-0 md:bg-transparent md:backdrop-blur-none md:border-none md:top-24 md:left-4 md:w-52 md:h-auto md:bottom-auto pointer-events-auto ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
{/* Hierarchical Navigation */} }`}>
<HierarchyNav <div className="space-y-2">
floorData={floorData} {/* Hierarchical Navigation */}
onRoomSelect={focusRoom} <HierarchyNav
onResetView={resetView}
/>
{/* 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>
)}
{/* Plant Library Browser */}
<div className="mt-2">
<PlantLibrary
floorData={floorData} floorData={floorData}
onPlantSelect={(plant, roomName, sectionCode) => { onRoomSelect={focusRoom}
// Find section and calculate position using same logic as SmartRack onResetView={resetView}
const room = floorData.rooms.find(r => r.name === roomName);
const section = room?.sections.find(s => (s.code || s.name) === sectionCode);
if (section) {
const { x, y, z } = calculatePlantCoords(
section,
{ row: plant.row, column: plant.column, tier: plant.tier },
floorData.floor.width,
floorData.floor.height
);
setFocusTarget({ x, z });
setCameraPreset('ISOMETRIC');
setBeaconPosition([x, y, z]);
setDimMode(false);
setHighlightedTags([plant.plant?.tagNumber || '']);
setSelectedPlant({
...plant,
x,
y,
z,
breadcrumb: {
room: roomName,
section: sectionCode,
},
});
}
}}
/> />
{/* 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>
)}
{/* Plant Library Browser */}
<div className="mt-2">
<PlantLibrary
floorData={floorData}
onPlantSelect={(plant, roomName, sectionCode) => {
// ... check logic ...
const room = floorData.rooms.find(r => r.name === roomName);
const section = room?.sections.find(s => (s.code || s.name) === sectionCode);
if (section) {
const { x, y, z } = calculatePlantCoords(
section,
{ row: plant.row, column: plant.column, tier: plant.tier },
floorData.floor.width,
floorData.floor.height
);
setFocusTarget({ x, z });
setCameraPreset('ISOMETRIC');
setBeaconPosition([x, y, z]);
setDimMode(false);
setHighlightedTags([plant.plant?.tagNumber || '']);
setSelectedPlant({
...plant,
x,
y,
z,
breadcrumb: {
room: roomName,
section: sectionCode,
},
});
setIsSidebarOpen(false); // Close mobile drawer
}
}}
/>
</div>
</div> </div>
</div> </div>
)} )}
{/* Visualization Mode Buttons */} {/* Sidebar Overlay for Mobile */}
<div className="absolute top-24 right-4 z-10 w-14 flex flex-col gap-2 pointer-events-auto"> {isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 md:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Visualization Mode Buttons - Responsive Position */}
<div className="absolute top-28 right-2 md:top-24 md:right-4 z-10 w-10 md:w-14 flex flex-col gap-2 pointer-events-auto">
{[ {[
{ mode: 'STANDARD' as VisMode, icon: Layers, label: 'Layout', active: 'bg-primary' }, { mode: 'STANDARD' as VisMode, icon: Layers, label: 'Layout', active: 'bg-primary' },
{ mode: 'HEALTH' as VisMode, icon: Activity, label: 'Health', active: 'bg-red-500' }, { mode: 'HEALTH' as VisMode, icon: Activity, label: 'Health', active: 'bg-red-500' },
@ -454,14 +468,14 @@ export default function Facility3DViewerPage() {
<button <button
key={mode} key={mode}
onClick={() => setVisMode(mode)} onClick={() => setVisMode(mode)}
className={`p-2.5 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === mode className={`p-2 md:p-2.5 rounded-lg md:rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === mode
? `${active} border-white/30 text-white scale-105` ? `${active} border-white/30 text-white scale-105`
: 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70' : 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70'
}`} }`}
title={label} title={label}
> >
<Icon size={22} className="mx-auto" /> <Icon size={18} className="mx-auto md:w-6 md:h-6" />
<span className="text-[9px] block text-center mt-0.5">{label}</span> <span className="text-[8px] md:text-[9px] block text-center mt-0.5 hidden md:block">{label}</span>
</button> </button>
))} ))}
@ -469,19 +483,19 @@ export default function Facility3DViewerPage() {
<div className="border-t border-slate-600 mt-1 pt-1"> <div className="border-t border-slate-600 mt-1 pt-1">
<button <button
onClick={() => setShowTimeline(!showTimeline)} onClick={() => setShowTimeline(!showTimeline)}
className={`w-full p-2.5 rounded-xl backdrop-blur-md shadow-lg transition-all border ${showTimeline className={`w-full p-2 md:p-2.5 rounded-lg md:rounded-xl backdrop-blur-md shadow-lg transition-all border ${showTimeline
? 'bg-cyan-600 border-cyan-400 text-white scale-105' ? 'bg-cyan-600 border-cyan-400 text-white scale-105'
: 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70' : 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70'
}`} }`}
title="Time Machine" title="Time Machine"
> >
<Clock size={22} className="mx-auto" /> <Clock size={16} className="mx-auto md:w-6 md:h-6" />
<span className="text-[9px] block text-center mt-0.5">Time</span> <span className="text-[8px] md:text-[9px] block text-center mt-0.5 hidden md:block">Time</span>
</button> </button>
</div> </div>
</div> </div>
{/* Loading/Error Overlay */} {/* Loading/Error Overlay - Unchanged */}
{status && ( {status && (
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/85 backdrop-blur-sm"> <div className="absolute inset-0 flex items-center justify-center z-50 bg-black/85 backdrop-blur-sm">
<div className="text-center"> <div className="text-center">
@ -496,9 +510,9 @@ export default function Facility3DViewerPage() {
</div> </div>
)} )}
{/* Plant Data Card - Persistent Side Panel */} {/* Plant Data Card - Responsive Bottom Sheet/Side Panel */}
{selectedPlant?.plant && ( {selectedPlant?.plant && (
<div className="absolute bottom-4 right-4 z-20 pointer-events-auto"> <div className="absolute bottom-0 left-0 right-0 z-20 pointer-events-auto md:bottom-4 md:right-4 md:left-auto">
<PlantDataCard <PlantDataCard
plant={selectedPlant} plant={selectedPlant}
onClose={() => { onClose={() => {
@ -509,7 +523,7 @@ export default function Facility3DViewerPage() {
</div> </div>
)} )}
{/* 3D Canvas */} {/* 3D Canvas - Unchanged */}
<Canvas camera={{ position: [25, 35, 25], fov: 55 }} shadows> <Canvas camera={{ position: [25, 35, 25], fov: 55 }} shadows>
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}> <Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
@ -529,9 +543,9 @@ export default function Facility3DViewerPage() {
</ErrorBoundary> </ErrorBoundary>
</Canvas> </Canvas>
{/* Timeline Slider - Phase 3 */} {/* Timeline Slider - Responsive Width */}
{showTimeline && ( {showTimeline && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 w-[90%] max-w-4xl pointer-events-auto"> <div className="absolute bottom-20 md:bottom-4 left-1/2 -translate-x-1/2 z-20 w-[95%] md:w-[90%] max-w-4xl pointer-events-auto transition-all duration-300">
<TimelineSlider <TimelineSlider
onDateChange={setTimelineDate} onDateChange={setTimelineDate}
isPlaying={isTimelinePlaying} isPlaying={isTimelinePlaying}