feat: Replace 3D viewer with clean 2D SVG layout + isometric toggle
Some checks failed
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled

This commit is contained in:
fullsizemalt 2025-12-27 21:58:44 -08:00
parent 3cc1830b6c
commit 1a13087c53
2 changed files with 253 additions and 2 deletions

View file

@ -0,0 +1,251 @@
import { useState, useMemo } from 'react';
import { cn } from '../../lib/utils';
interface RackData {
id: string;
name: string;
posX: number;
posY: number;
width: number;
height: number;
plantCount?: number;
healthScore?: number;
}
interface RoomLayout2DProps {
room: {
id: string;
name: string;
width?: number;
height?: number;
sections?: RackData[];
racks?: RackData[];
};
onRackClick?: (rack: RackData) => void;
}
export function RoomLayout2D({ room, onRackClick }: RoomLayout2DProps) {
const [viewMode, setViewMode] = useState<'overhead' | 'isometric'>('isometric');
// Room dimensions (use defaults if not provided)
const roomWidth = room.width || 50;
const roomHeight = room.height || 40;
// Get racks from sections or racks property, or generate mock data
const racks: RackData[] = useMemo(() => {
if (room.sections && room.sections.length > 0) {
return room.sections;
}
if (room.racks && room.racks.length > 0) {
return room.racks;
}
// Generate mock racks for demo
return Array.from({ length: 4 }).map((_, i) => ({
id: `rack-${i}`,
name: `Rack ${String(i + 1).padStart(2, '0')}`,
posX: 5 + (i * 11),
posY: 8,
width: 8,
height: 24,
plantCount: 12 + (i * 4),
healthScore: 85 + (i * 3),
}));
}, [room.sections, room.racks]);
// SVG viewBox (add padding)
const padding = 4;
const viewBox = `${-padding} ${-padding} ${roomWidth + padding * 2} ${roomHeight + padding * 2}`;
// Health score to color
const getHealthColor = (score?: number) => {
if (!score) return '#64748b';
if (score >= 90) return '#22c55e';
if (score >= 75) return '#eab308';
return '#ef4444';
};
return (
<div className="w-full h-[600px] bg-slate-950 rounded-2xl overflow-hidden border border-[var(--color-border-subtle)] relative">
{/* SVG Container with perspective for isometric mode */}
<div
className={cn(
"w-full h-full flex items-center justify-center transition-transform duration-500 ease-out",
viewMode === 'isometric' && "perspective-[1000px]"
)}
style={{ perspective: viewMode === 'isometric' ? '1000px' : 'none' }}
>
<svg
viewBox={viewBox}
className={cn(
"w-full h-full max-w-[90%] max-h-[90%] transition-transform duration-500 ease-out",
viewMode === 'isometric' && "transform-gpu"
)}
style={{
transform: viewMode === 'isometric'
? 'rotateX(60deg) rotateZ(-45deg) scale(0.7)'
: 'none',
transformStyle: 'preserve-3d',
}}
>
{/* Room Floor */}
<rect
x={0}
y={0}
width={roomWidth}
height={roomHeight}
fill="#1e293b"
stroke="#334155"
strokeWidth={0.3}
rx={1}
/>
{/* Grid Lines */}
{Array.from({ length: Math.floor(roomWidth / 5) + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * 5}
y1={0}
x2={i * 5}
y2={roomHeight}
stroke="#334155"
strokeWidth={0.1}
opacity={0.5}
/>
))}
{Array.from({ length: Math.floor(roomHeight / 5) + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * 5}
x2={roomWidth}
y2={i * 5}
stroke="#334155"
strokeWidth={0.1}
opacity={0.5}
/>
))}
{/* Racks */}
{racks.map((rack) => (
<g
key={rack.id}
className="cursor-pointer transition-all hover:opacity-80"
onClick={() => onRackClick?.(rack)}
>
{/* Rack base (shadow) */}
<rect
x={rack.posX + 0.3}
y={rack.posY + 0.3}
width={rack.width}
height={rack.height}
fill="#000"
opacity={0.3}
rx={0.5}
/>
{/* Rack body */}
<rect
x={rack.posX}
y={rack.posY}
width={rack.width}
height={rack.height}
fill="#475569"
stroke={getHealthColor(rack.healthScore)}
strokeWidth={0.4}
rx={0.5}
/>
{/* Rack label */}
<text
x={rack.posX + rack.width / 2}
y={rack.posY + rack.height / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="#f8fafc"
fontSize={2.5}
fontWeight="bold"
fontFamily="monospace"
style={{
transform: viewMode === 'isometric'
? 'rotateZ(45deg) rotateX(-60deg)'
: 'none',
transformOrigin: `${rack.posX + rack.width / 2}px ${rack.posY + rack.height / 2}px`,
transformBox: 'fill-box',
}}
>
{rack.name}
</text>
{/* Plant count indicator */}
{rack.plantCount && (
<g>
<circle
cx={rack.posX + rack.width - 1.5}
cy={rack.posY + 1.5}
r={1.8}
fill="#10b981"
/>
<text
x={rack.posX + rack.width - 1.5}
y={rack.posY + 1.5}
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={1.2}
fontWeight="bold"
>
{rack.plantCount}
</text>
</g>
)}
</g>
))}
{/* Room Name Label */}
<text
x={roomWidth / 2}
y={-1.5}
textAnchor="middle"
fill="#94a3b8"
fontSize={2}
fontWeight="600"
style={{
transform: viewMode === 'isometric'
? 'rotateZ(45deg) rotateX(-60deg)'
: 'none',
transformOrigin: `${roomWidth / 2}px -1.5px`,
}}
>
{room.name.replace('[DEMO] ', '')}
</text>
</svg>
</div>
{/* View Mode Toggle */}
<div className="absolute bottom-4 right-4 flex items-center gap-2">
<button
onClick={() => setViewMode(viewMode === 'overhead' ? 'isometric' : 'overhead')}
className="bg-slate-900/90 backdrop-blur-md px-3 py-1.5 rounded-md border border-emerald-500/30 text-xs font-mono text-emerald-400 hover:bg-slate-800/90 hover:border-emerald-500/50 transition-all cursor-pointer shadow-xl"
>
{viewMode === 'overhead' ? '2D → ISO' : 'ISO → 2D'}
</button>
<div className="bg-slate-900/90 backdrop-blur-md px-3 py-1.5 rounded-md border border-emerald-500/20 text-xs font-mono text-emerald-400 pointer-events-none shadow-xl uppercase">
{viewMode === 'overhead' ? 'OVERHEAD' : 'ISOMETRIC'}
</div>
</div>
{/* Legend */}
<div className="absolute top-4 left-4 bg-slate-900/90 backdrop-blur-md px-3 py-2 rounded-md border border-slate-700/50 text-xs space-y-1">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-[#22c55e]"></span>
<span className="text-slate-400">Healthy (90%+)</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-[#eab308]"></span>
<span className="text-slate-400">Warning (75-89%)</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-[#ef4444]"></span>
<span className="text-slate-400">Critical (&lt;75%)</span>
</div>
</div>
</div>
);
}

View file

@ -9,7 +9,7 @@ import api from '../lib/api';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Room3DViewer } from '../components/facility3d/Room3DViewer'; import { RoomLayout2D } from '../components/facility2d/RoomLayout2D';
// Mock sensor data generator for Sparklines // Mock sensor data generator for Sparklines
function generateMockData(count: number, min: number, max: number) { function generateMockData(count: number, min: number, max: number) {
@ -178,7 +178,7 @@ export default function RoomDetailPage() {
<div className="min-h-[400px]"> <div className="min-h-[400px]">
{activeTab === '3d' && ( {activeTab === '3d' && (
<Room3DViewer room={room} /> <RoomLayout2D room={room} />
)} )}
{activeTab === 'batches' && ( {activeTab === 'batches' && (