feat: Replace 3D viewer with clean 2D SVG layout + isometric toggle
This commit is contained in:
parent
3cc1830b6c
commit
1a13087c53
2 changed files with 253 additions and 2 deletions
251
frontend/src/components/facility2d/RoomLayout2D.tsx
Normal file
251
frontend/src/components/facility2d/RoomLayout2D.tsx
Normal 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 (<75%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import api from '../lib/api';
|
|||
import { Card } from '../components/ui/card';
|
||||
import { cn } from '../lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Room3DViewer } from '../components/facility3d/Room3DViewer';
|
||||
import { RoomLayout2D } from '../components/facility2d/RoomLayout2D';
|
||||
|
||||
// Mock sensor data generator for Sparklines
|
||||
function generateMockData(count: number, min: number, max: number) {
|
||||
|
|
@ -178,7 +178,7 @@ export default function RoomDetailPage() {
|
|||
|
||||
<div className="min-h-[400px]">
|
||||
{activeTab === '3d' && (
|
||||
<Room3DViewer room={room} />
|
||||
<RoomLayout2D room={room} />
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue