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}`;
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 */}
<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">
@ -75,8 +75,8 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
{plantInfo.tagNumber}
</span>
<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' :
'bg-blue-600/30 text-blue-300'
plantInfo.stage === 'VEGETATIVE' ? 'bg-green-600/30 text-green-300' :
'bg-blue-600/30 text-blue-300'
}`}>
{plantInfo.stage?.replace('_', ' ') || 'VEG'}
</span>

View file

@ -1,8 +1,9 @@
// ... imports ...
import { useEffect, useState, Suspense, Component, ReactNode, useCallback } from 'react';
import { Canvas } from '@react-three/fiber';
import { Html, CameraControls } from '@react-three/drei';
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 { FacilityScene } from '../components/facility3d/FacilityScene';
import { PlantSearch } from '../components/facility3d/PlantSearch';
@ -15,10 +16,8 @@ import { HierarchyBreadcrumb } from '../components/facility3d/HierarchyBreadcrum
import { PlantDataCard } from '../components/facility3d/PlantDataCard';
import { SCALE } from '../components/facility3d/coordinates';
/**
* Calculate plant world coordinates matching SmartRack's grid logic exactly.
* This ensures beacon position matches where plants are actually rendered.
*/
// ... calculatePlantCoords and ErrorBoundary remain unchanged ...
function calculatePlantCoords(
section: Section3D,
position: { row: number; column: number; tier: number },
@ -87,6 +86,9 @@ export default function Facility3DViewerPage() {
const [selectedPlant, setSelectedPlant] = useState<PlantPosition | null>(null);
const [visMode, setVisMode] = useState<VisMode>('STANDARD');
// Mobile Nav State
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Phase 2: Search state
const [highlightedTags, setHighlightedTags] = useState<string[]>([]);
const [dimMode, setDimMode] = useState(false);
@ -201,6 +203,8 @@ export default function Facility3DViewerPage() {
setBeaconPosition(null);
setHighlightedTags([]);
setDimMode(false);
// Mobile UX: Close sidebar after selection
setIsSidebarOpen(false);
};
const resetView = () => {
@ -292,22 +296,31 @@ export default function Facility3DViewerPage() {
return (
<div className="h-screen w-full relative bg-slate-950 text-white overflow-hidden font-sans">
{/* 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="pointer-events-auto flex items-center gap-4">
<Link to="/metrc" className="btn btn-ghost btn-sm text-white">
<ArrowLeft size={16} /> Back
<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-3">
{/* Mobile Menu Toggle */}
<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>
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
Facility 3D
<span className="badge badge-accent text-xs">BETA</span>
<h1 className="text-sm md:text-xl font-bold flex items-center gap-2">
<span className="hidden sm:inline">Facility 3D</span>
<span className="sm:hidden">3D View</span>
<span className="badge badge-accent text-[10px] md:text-xs">BETA</span>
</h1>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{allFloors.length > 1 ? (
<select
value={selectedFloorId || ''}
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 => (
<option key={f.id} value={f.id}>
@ -320,16 +333,16 @@ export default function Facility3DViewerPage() {
{floorData?.floor.name || 'Loading...'}
</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
</span>
</div>
</div>
</div>
{/* Search Bar */}
{/* Search Bar - Flexible width on mobile */}
{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
floorData={floorData}
onSelectResult={handleSearchSelect}
@ -338,8 +351,8 @@ export default function Facility3DViewerPage() {
</div>
)}
{/* Camera Preset Selector */}
<div className="pointer-events-auto">
{/* Camera Preset Selector - Hide on very small screens if crowded */}
<div className="pointer-events-auto hidden sm:block">
<CameraPresetSelector
current={cameraPreset}
onChange={(preset) => {
@ -347,8 +360,10 @@ export default function Facility3DViewerPage() {
}}
/>
</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">
{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>
</>
)}
{visMode === 'TEMP' && (
<>
<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>
</>
)}
{/* ... other legend items ... */}
</div>
</div>
{/* Room Navigation Sidebar */}
{/* Room Navigation Sidebar - Responsive Drawer */}
{floorData && (
<div className="absolute top-24 left-4 z-10 w-52 pointer-events-auto space-y-2">
{/* Hierarchical Navigation */}
<HierarchyNav
floorData={floorData}
onRoomSelect={focusRoom}
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
<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'
}`}>
<div className="space-y-2">
{/* Hierarchical Navigation */}
<HierarchyNav
floorData={floorData}
onPlantSelect={(plant, roomName, sectionCode) => {
// Find section and calculate position using same logic as SmartRack
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,
},
});
}
}}
onRoomSelect={focusRoom}
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}
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>
)}
{/* Visualization Mode Buttons */}
<div className="absolute top-24 right-4 z-10 w-14 flex flex-col gap-2 pointer-events-auto">
{/* Sidebar Overlay for Mobile */}
{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: 'HEALTH' as VisMode, icon: Activity, label: 'Health', active: 'bg-red-500' },
@ -454,14 +468,14 @@ export default function Facility3DViewerPage() {
<button
key={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`
: 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70'
}`}
title={label}
>
<Icon size={22} className="mx-auto" />
<span className="text-[9px] block text-center mt-0.5">{label}</span>
<Icon size={18} className="mx-auto md:w-6 md:h-6" />
<span className="text-[8px] md:text-[9px] block text-center mt-0.5 hidden md:block">{label}</span>
</button>
))}
@ -469,19 +483,19 @@ export default function Facility3DViewerPage() {
<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
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-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>
<Clock size={16} className="mx-auto md:w-6 md:h-6" />
<span className="text-[8px] md:text-[9px] block text-center mt-0.5 hidden md:block">Time</span>
</button>
</div>
</div>
{/* Loading/Error Overlay */}
{/* Loading/Error Overlay - Unchanged */}
{status && (
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/85 backdrop-blur-sm">
<div className="text-center">
@ -496,9 +510,9 @@ export default function Facility3DViewerPage() {
</div>
)}
{/* Plant Data Card - Persistent Side Panel */}
{/* Plant Data Card - Responsive Bottom Sheet/Side Panel */}
{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
plant={selectedPlant}
onClose={() => {
@ -509,7 +523,7 @@ export default function Facility3DViewerPage() {
</div>
)}
{/* 3D Canvas */}
{/* 3D Canvas - Unchanged */}
<Canvas camera={{ position: [25, 35, 25], fov: 55 }} shadows>
<ErrorBoundary>
<Suspense fallback={<Html center>Loading 3D Scene...</Html>}>
@ -529,9 +543,9 @@ export default function Facility3DViewerPage() {
</ErrorBoundary>
</Canvas>
{/* Timeline Slider - Phase 3 */}
{/* Timeline Slider - Responsive Width */}
{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
onDateChange={setTimelineDate}
isPlaying={isTimelinePlaying}