refactor: decompose 3d viewer into clean components with industrial styling
This commit is contained in:
parent
182e8c7d1a
commit
0758c4ffe6
7 changed files with 567 additions and 526 deletions
109
frontend/src/components/facility3d/FacilityScene.tsx
Normal file
109
frontend/src/components/facility3d/FacilityScene.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { CameraControls, Environment, ContactShadows } from '@react-three/drei';
|
||||||
|
import type { Floor3DData, Room3D } from '../../lib/layoutApi';
|
||||||
|
import { RoomObject } from './RoomObject';
|
||||||
|
import { PlantPosition, VisMode } from './types';
|
||||||
|
|
||||||
|
interface FacilitySceneProps {
|
||||||
|
data: Floor3DData;
|
||||||
|
visMode: VisMode;
|
||||||
|
targetView: { x: number; y: number; z: number; zoom?: boolean } | null;
|
||||||
|
onPlantClick: (plant: PlantPosition) => void;
|
||||||
|
onControlsReady: (controls: CameraControls) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FacilityScene({
|
||||||
|
data,
|
||||||
|
visMode,
|
||||||
|
targetView,
|
||||||
|
onPlantClick,
|
||||||
|
onControlsReady,
|
||||||
|
}: FacilitySceneProps) {
|
||||||
|
const controlsRef = useRef<CameraControls>(null);
|
||||||
|
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlsRef.current) onControlsReady(controlsRef.current);
|
||||||
|
}, [controlsRef.current, onControlsReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetView && controlsRef.current) {
|
||||||
|
const { x, z, zoom } = targetView;
|
||||||
|
const dist = zoom ? 12 : 35;
|
||||||
|
const height = zoom ? 10 : 30;
|
||||||
|
controlsRef.current.setLookAt(x + dist, height, z + dist, x, 0, z, true);
|
||||||
|
}
|
||||||
|
}, [targetView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDown = (e: KeyboardEvent) => {
|
||||||
|
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setKeysPressed(k => ({ ...k, [e.code]: true }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleUp = (e: KeyboardEvent) => {
|
||||||
|
setKeysPressed(k => ({ ...k, [e.code]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleDown);
|
||||||
|
window.addEventListener('keyup', handleUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleDown);
|
||||||
|
window.removeEventListener('keyup', handleUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (!controlsRef.current) return;
|
||||||
|
const speed = 18 * delta;
|
||||||
|
if (keysPressed['KeyW'] || keysPressed['ArrowUp']) controlsRef.current.forward(speed, true);
|
||||||
|
if (keysPressed['KeyS'] || keysPressed['ArrowDown']) controlsRef.current.forward(-speed, true);
|
||||||
|
if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) controlsRef.current.truck(-speed, 0, true);
|
||||||
|
if (keysPressed['KeyD'] || keysPressed['ArrowRight']) controlsRef.current.truck(speed, 0, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Environment preset="city" background={false} />
|
||||||
|
<ambientLight intensity={0.6} />
|
||||||
|
<directionalLight
|
||||||
|
position={[-15, 60, -15]}
|
||||||
|
intensity={1.2}
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize={[2048, 2048]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<group position={[-data.floor.width / 2, 0, -data.floor.height / 2]}>
|
||||||
|
{data.rooms.map((room: Room3D) => (
|
||||||
|
<RoomObject
|
||||||
|
key={room.id}
|
||||||
|
room={room}
|
||||||
|
visMode={visMode}
|
||||||
|
onPlantClick={onPlantClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<ContactShadows
|
||||||
|
position={[0, -0.02, 0]}
|
||||||
|
opacity={0.35}
|
||||||
|
scale={120}
|
||||||
|
blur={2}
|
||||||
|
far={12}
|
||||||
|
resolution={512}
|
||||||
|
color="#000"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CameraControls
|
||||||
|
ref={controlsRef}
|
||||||
|
minDistance={4}
|
||||||
|
maxDistance={120}
|
||||||
|
dollySpeed={0.8}
|
||||||
|
truckSpeed={1.5}
|
||||||
|
makeDefault
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/components/facility3d/PlantSystem.tsx
Normal file
93
frontend/src/components/facility3d/PlantSystem.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Instances, Instance } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { PlantPosition, VisMode, COLORS } from './types';
|
||||||
|
|
||||||
|
// Mock helpers
|
||||||
|
const getMockPlantHealth = (_plantId: string) => {
|
||||||
|
const r = Math.random();
|
||||||
|
if (r > 0.95) return 'CRITICAL';
|
||||||
|
if (r > 0.85) return 'WARNING';
|
||||||
|
return 'GOOD';
|
||||||
|
};
|
||||||
|
|
||||||
|
const lerpColor = (c1: string, c2: string, t: number) => {
|
||||||
|
const c1c = new THREE.Color(c1);
|
||||||
|
const c2c = new THREE.Color(c2);
|
||||||
|
return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlantSystemProps {
|
||||||
|
positions: PlantPosition[];
|
||||||
|
visMode: VisMode;
|
||||||
|
onPlantClick: (plant: PlantPosition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlantSystem({ positions, visMode, onPlantClick }: PlantSystemProps) {
|
||||||
|
if (!positions || positions.length === 0) return null;
|
||||||
|
|
||||||
|
const plants = positions.filter(p => p.plant);
|
||||||
|
const emptySlots = positions.filter(p => !p.plant);
|
||||||
|
|
||||||
|
if (plants.length > 5000) return null;
|
||||||
|
|
||||||
|
const plantColors = useMemo(() => {
|
||||||
|
return plants.map(pos => {
|
||||||
|
switch (visMode) {
|
||||||
|
case 'STANDARD':
|
||||||
|
return pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER :
|
||||||
|
pos.plant?.stage === 'DRYING' ? COLORS.DRY :
|
||||||
|
pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG;
|
||||||
|
case 'HEALTH':
|
||||||
|
const status = getMockPlantHealth(pos.plant!.id);
|
||||||
|
return status === 'CRITICAL' ? COLORS.HEALTH_CRIT :
|
||||||
|
status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD;
|
||||||
|
case 'YIELD':
|
||||||
|
return lerpColor('#86efac', '#14532d', Math.random());
|
||||||
|
default:
|
||||||
|
return '#555';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [plants, visMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{plants.length > 0 && (
|
||||||
|
<Instances
|
||||||
|
range={plants.length}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const index = e.instanceId;
|
||||||
|
if (index !== undefined && plants[index]) {
|
||||||
|
onPlantClick(plants[index]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.2, 12, 8]} />
|
||||||
|
<meshStandardMaterial roughness={0.5} />
|
||||||
|
{plants.map((pos, i) => (
|
||||||
|
<Instance
|
||||||
|
key={pos.id || i}
|
||||||
|
position={[pos.x, pos.y + 0.25, pos.z]}
|
||||||
|
color={plantColors[i]}
|
||||||
|
scale={1.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Instances>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emptySlots.length > 0 && visMode === 'STANDARD' && (
|
||||||
|
<Instances range={emptySlots.length}>
|
||||||
|
<cylinderGeometry args={[0.04, 0.04, 0.05, 6]} />
|
||||||
|
<meshStandardMaterial color={COLORS.EMPTY_SLOT} />
|
||||||
|
{emptySlots.map((pos, i) => (
|
||||||
|
<Instance
|
||||||
|
key={pos.id || i}
|
||||||
|
position={[pos.x, pos.y + 0.03, pos.z]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Instances>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/components/facility3d/RoomObject.tsx
Normal file
98
frontend/src/components/facility3d/RoomObject.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { Text } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Room3D } from '../../lib/layoutApi';
|
||||||
|
import { PlantPosition, VisMode, COLORS } from './types';
|
||||||
|
import { SmartRack } from './SmartRack';
|
||||||
|
|
||||||
|
// Mock environment data
|
||||||
|
const getMockRoomEnv = (roomName: string) => {
|
||||||
|
const hash = roomName.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||||
|
return {
|
||||||
|
temp: 68 + (hash % 15),
|
||||||
|
humidity: 40 + (hash % 40),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const lerpColor = (c1: string, c2: string, t: number) => {
|
||||||
|
const c1c = new THREE.Color(c1);
|
||||||
|
const c2c = new THREE.Color(c2);
|
||||||
|
return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoomObjectProps {
|
||||||
|
room: Room3D;
|
||||||
|
visMode: VisMode;
|
||||||
|
onPlantClick: (plant: PlantPosition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomObject({ room, visMode, onPlantClick }: RoomObjectProps) {
|
||||||
|
const env = getMockRoomEnv(room.name);
|
||||||
|
|
||||||
|
let floorColor: string = COLORS.ROOM_FLOOR;
|
||||||
|
if (visMode === 'TEMP') {
|
||||||
|
const t = (env.temp - 65) / 20;
|
||||||
|
floorColor = t < 0.5
|
||||||
|
? lerpColor(COLORS.TEMP_LOW, COLORS.TEMP_OPTIMAL, t * 2)
|
||||||
|
: lerpColor(COLORS.TEMP_OPTIMAL, COLORS.TEMP_HIGH, (t - 0.5) * 2);
|
||||||
|
} else if (visMode === 'HUMIDITY') {
|
||||||
|
const t = (env.humidity - 40) / 40;
|
||||||
|
floorColor = t < 0.5
|
||||||
|
? lerpColor(COLORS.HUMIDITY_DRY, COLORS.HUMIDITY_OPTIMAL, t * 2)
|
||||||
|
: lerpColor(COLORS.HUMIDITY_OPTIMAL, COLORS.HUMIDITY_WET, (t - 0.5) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[room.posX, 0, room.posY]}>
|
||||||
|
<Text
|
||||||
|
position={[room.width / 2, 0.05, room.height / 2]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={Math.min(room.width, room.height) / 6}
|
||||||
|
color="#f8fafc"
|
||||||
|
fillOpacity={0.7}
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{(visMode === 'TEMP' || visMode === 'HUMIDITY') && (
|
||||||
|
<Text
|
||||||
|
position={[room.width / 2, 4, room.height / 2]}
|
||||||
|
rotation={[-Math.PI / 4, 0, 0]}
|
||||||
|
fontSize={4}
|
||||||
|
color="#fff"
|
||||||
|
outlineColor="#000"
|
||||||
|
outlineWidth={0.15}
|
||||||
|
anchorX="center"
|
||||||
|
>
|
||||||
|
{visMode === 'TEMP' ? `${env.temp}°F` : `${env.humidity}%`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[room.width / 2, 0, room.height / 2]} receiveShadow>
|
||||||
|
<planeGeometry args={[room.width, room.height]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={floorColor}
|
||||||
|
roughness={0.95}
|
||||||
|
metalness={0.05}
|
||||||
|
transparent={visMode !== 'STANDARD'}
|
||||||
|
opacity={visMode !== 'STANDARD' ? 0.85 : 1}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<lineSegments position={[room.width / 2, 0.02, room.height / 2]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<edgesGeometry args={[new THREE.PlaneGeometry(room.width, room.height)]} />
|
||||||
|
<lineBasicMaterial color="#64748b" />
|
||||||
|
</lineSegments>
|
||||||
|
|
||||||
|
{room.sections.map(section => (
|
||||||
|
<SmartRack
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
visMode={visMode}
|
||||||
|
onPlantClick={onPlantClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/facility3d/SmartRack.tsx
Normal file
113
frontend/src/components/facility3d/SmartRack.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Text } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { Section3D, Position3D } from '../../lib/layoutApi';
|
||||||
|
import { PlantPosition, VisMode } from './types';
|
||||||
|
import { PlantSystem } from './PlantSystem';
|
||||||
|
|
||||||
|
interface SmartRackProps {
|
||||||
|
section: Section3D;
|
||||||
|
visMode: VisMode;
|
||||||
|
onPlantClick: (plant: PlantPosition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartRack({ section, visMode, onPlantClick }: SmartRackProps) {
|
||||||
|
const positions: PlantPosition[] = useMemo(() => {
|
||||||
|
const spacing = 0.5;
|
||||||
|
return section.positions.map((pos: Position3D) => ({
|
||||||
|
...pos,
|
||||||
|
x: section.posX + (pos.column * spacing),
|
||||||
|
z: section.posY + (pos.row * spacing),
|
||||||
|
y: 0.4 + (pos.tier * 0.6),
|
||||||
|
}));
|
||||||
|
}, [section]);
|
||||||
|
|
||||||
|
const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b);
|
||||||
|
const distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b);
|
||||||
|
const distinctCols = [...new Set(positions.map(p => p.column))].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const spacing = 0.5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{visMode === 'STANDARD' && (
|
||||||
|
<Text
|
||||||
|
position={[section.posX + section.width / 2, 3, section.posY + section.height / 2]}
|
||||||
|
fontSize={0.8}
|
||||||
|
color="#94a3b8"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="bottom"
|
||||||
|
outlineColor="#000"
|
||||||
|
outlineWidth={0.03}
|
||||||
|
>
|
||||||
|
{section.code}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{distinctTiers.map(tier => (
|
||||||
|
<mesh
|
||||||
|
key={`shelf-${tier}`}
|
||||||
|
position={[
|
||||||
|
section.posX + section.width / 2,
|
||||||
|
0.35 + (tier * 0.6),
|
||||||
|
section.posY + section.height / 2
|
||||||
|
]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<planeGeometry args={[section.width * 0.95, section.height * 0.95]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#64748b"
|
||||||
|
roughness={0.3}
|
||||||
|
metalness={0.7}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{[0, 1].map(xOffset =>
|
||||||
|
[0, 1].map(zOffset => (
|
||||||
|
<mesh
|
||||||
|
key={`support-${xOffset}-${zOffset}`}
|
||||||
|
position={[
|
||||||
|
section.posX + (xOffset * section.width),
|
||||||
|
(distinctTiers.length * 0.6) / 2 + 0.2,
|
||||||
|
section.posY + (zOffset * section.height)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[0.08, distinctTiers.length * 0.6 + 0.3, 0.08]} />
|
||||||
|
<meshStandardMaterial color="#374151" roughness={0.4} metalness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visMode === 'STANDARD' && distinctRows.map(row => (
|
||||||
|
<Text
|
||||||
|
key={`row-${row}`}
|
||||||
|
position={[section.posX - 0.3, 0.8, section.posY + row * spacing]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={0.18}
|
||||||
|
color="#64748b"
|
||||||
|
anchorX="right"
|
||||||
|
>
|
||||||
|
R{row}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visMode === 'STANDARD' && distinctCols.map(col => (
|
||||||
|
<Text
|
||||||
|
key={`col-${col}`}
|
||||||
|
position={[section.posX + col * spacing, 0.15, section.posY + section.height + 0.2]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={0.18}
|
||||||
|
color="#64748b"
|
||||||
|
anchorX="center"
|
||||||
|
>
|
||||||
|
C{col}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PlantSystem positions={positions} visMode={visMode} onPlantClick={onPlantClick} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/components/facility3d/index.ts
Normal file
5
frontend/src/components/facility3d/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { FacilityScene } from './FacilityScene';
|
||||||
|
export { RoomObject } from './RoomObject';
|
||||||
|
export { SmartRack } from './SmartRack';
|
||||||
|
export { PlantSystem } from './PlantSystem';
|
||||||
|
export * from './types';
|
||||||
37
frontend/src/components/facility3d/types.ts
Normal file
37
frontend/src/components/facility3d/types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// Re-export types from layoutApi for consistency
|
||||||
|
import type { Room3D, Section3D, Position3D, Floor3DData } from '../../lib/layoutApi';
|
||||||
|
|
||||||
|
export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
|
||||||
|
|
||||||
|
// Re-export API types under cleaner names
|
||||||
|
export type RoomData = Room3D;
|
||||||
|
export type SectionData = Section3D;
|
||||||
|
export type PlantPosition = Position3D & {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLORS = {
|
||||||
|
// Standard Modes
|
||||||
|
VEG: '#4ade80',
|
||||||
|
FLOWER: '#a855f7',
|
||||||
|
DRY: '#f59e0b',
|
||||||
|
CURE: '#78716c',
|
||||||
|
EMPTY_SLOT: '#475569',
|
||||||
|
ROOM_FLOOR: '#334155',
|
||||||
|
ROOM_WALL: '#475569',
|
||||||
|
|
||||||
|
// Health Mode
|
||||||
|
HEALTH_GOOD: '#22c55e',
|
||||||
|
HEALTH_WARN: '#eab308',
|
||||||
|
HEALTH_CRIT: '#ef4444',
|
||||||
|
|
||||||
|
// Environment Heatmaps
|
||||||
|
TEMP_LOW: '#3b82f6',
|
||||||
|
TEMP_OPTIMAL: '#22c55e',
|
||||||
|
TEMP_HIGH: '#ef4444',
|
||||||
|
HUMIDITY_DRY: '#f59e0b',
|
||||||
|
HUMIDITY_OPTIMAL: '#0ea5e9',
|
||||||
|
HUMIDITY_WET: '#3b82f6',
|
||||||
|
} as const;
|
||||||
|
|
@ -1,79 +1,22 @@
|
||||||
import { useEffect, useState, Suspense, useMemo, Component, ReactNode, useRef } from 'react';
|
import { useEffect, useState, Suspense, Component, ReactNode } from 'react';
|
||||||
import { Canvas, useFrame } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { Text, Instances, Instance, Html, CameraControls, Environment, ContactShadows } from '@react-three/drei';
|
import { Html, CameraControls } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import { layoutApi, Floor3DData, Room3D, Position3D } from '../lib/layoutApi';
|
||||||
import { layoutApi, Floor3DData } from '../lib/layoutApi';
|
|
||||||
import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf } from 'lucide-react';
|
import { Loader2, ArrowLeft, Maximize, MousePointer2, Layers, Thermometer, Droplets, Activity, Leaf } from 'lucide-react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { FacilityScene } from '../components/facility3d/FacilityScene';
|
||||||
|
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
|
||||||
|
|
||||||
// --- Visualization Types & Config ---
|
// --- Error Boundary ---
|
||||||
type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
|
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
// Standard Modes
|
|
||||||
VEG: '#4ade80', // green-400
|
|
||||||
FLOWER: '#a855f7', // purple-500
|
|
||||||
DRY: '#f59e0b', // amber-500
|
|
||||||
CURE: '#78716c', // stone-500
|
|
||||||
EMPTY_SLOT: '#374151', // gray-700
|
|
||||||
ROOM_FLOOR: '#1f2937', // gray-800
|
|
||||||
ROOM_WALL: '#374151', // gray-700
|
|
||||||
|
|
||||||
// Health Mode
|
|
||||||
HEALTH_GOOD: '#22c55e',
|
|
||||||
HEALTH_WARN: '#eab308',
|
|
||||||
HEALTH_CRIT: '#ef4444',
|
|
||||||
|
|
||||||
// Environment Heatmaps (Low -> High)
|
|
||||||
TEMP_LOW: '#3b82f6', // Cold (Blue)
|
|
||||||
TEMP_OPTIMAL: '#22c55e', // Good (Green)
|
|
||||||
TEMP_HIGH: '#ef4444', // Hot (Red)
|
|
||||||
|
|
||||||
HUMIDITY_DRY: '#f59e0b', // Dry (Amber)
|
|
||||||
HUMIDITY_OPTIMAL: '#0ea5e9', // Good (Sky Blue)
|
|
||||||
HUMIDITY_WET: '#3b82f6', // Wet (Dark Blue)
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Mock Data Generators ---
|
|
||||||
const getMockRoomEnv = (roomName: string) => {
|
|
||||||
const hash = roomName.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
|
||||||
const temp = 68 + (hash % 15); // 68-83 deg F
|
|
||||||
const humidity = 40 + (hash % 40); // 40-80% RH
|
|
||||||
return { temp, humidity };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMockPlantHealth = (plantId: string) => {
|
|
||||||
const r = Math.random(); // Deterministic enough for this demo if we seed it, but random is okay for flashiness
|
|
||||||
if (r > 0.95) return 'CRITICAL';
|
|
||||||
if (r > 0.85) return 'WARNING';
|
|
||||||
return 'GOOD';
|
|
||||||
};
|
|
||||||
|
|
||||||
const lerpColor = (c1: string, c2: string, t: number) => {
|
|
||||||
const c1c = new THREE.Color(c1);
|
|
||||||
const c2c = new THREE.Color(c2);
|
|
||||||
return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define State interface
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
|
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
|
||||||
constructor(props: { children: ReactNode }) {
|
state: ErrorBoundaryState = { hasError: false, error: null };
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
return { hasError: true, error };
|
return { hasError: true, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
console.error("3D Viewer Error Caught:", this.state.error);
|
|
||||||
return (
|
return (
|
||||||
<Html center>
|
<Html center>
|
||||||
<div className="bg-red-900/90 text-white p-4 rounded border border-red-500 max-w-md">
|
<div className="bg-red-900/90 text-white p-4 rounded border border-red-500 max-w-md">
|
||||||
|
|
@ -87,372 +30,40 @@ class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryStat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlantInstances({ positions, onPlantClick, visMode }: {
|
// --- Main Component ---
|
||||||
positions: any[],
|
|
||||||
onPlantClick: (p: any) => void,
|
|
||||||
visMode: VisMode
|
|
||||||
}) {
|
|
||||||
if (!positions || !Array.isArray(positions) || positions.length === 0) return null;
|
|
||||||
|
|
||||||
const safePositions = positions.filter(p => p && typeof p.x === 'number' && typeof p.y === 'number' && typeof p.z === 'number');
|
|
||||||
const plants = safePositions.filter(p => p.plant);
|
|
||||||
const emptySlots = safePositions.filter(p => !p.plant);
|
|
||||||
|
|
||||||
if (safePositions.length > 5000) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{/* Active Plants */}
|
|
||||||
{plants.length > 0 && (
|
|
||||||
<Instances
|
|
||||||
range={plants.length}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const index = e.instanceId;
|
|
||||||
if (index !== undefined && plants[index]) {
|
|
||||||
onPlantClick(plants[index]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Bushy Top */}
|
|
||||||
<dodecahedronGeometry args={[0.25, 0]} />
|
|
||||||
<meshStandardMaterial roughness={0.6} activeStripes={true} />
|
|
||||||
{plants.map((pos, i) => {
|
|
||||||
let color = COLORS.VEG;
|
|
||||||
|
|
||||||
switch (visMode) {
|
|
||||||
case 'STANDARD':
|
|
||||||
color = pos.plant?.stage === 'FLOWER' ? COLORS.FLOWER :
|
|
||||||
pos.plant?.stage === 'DRYING' ? COLORS.DRY :
|
|
||||||
pos.plant?.stage === 'CURE' ? COLORS.CURE : COLORS.VEG;
|
|
||||||
break;
|
|
||||||
case 'HEALTH':
|
|
||||||
const status = getMockPlantHealth(pos.plant.id);
|
|
||||||
color = status === 'CRITICAL' ? COLORS.HEALTH_CRIT :
|
|
||||||
status === 'WARNING' ? COLORS.HEALTH_WARN : COLORS.HEALTH_GOOD;
|
|
||||||
break;
|
|
||||||
case 'YIELD':
|
|
||||||
color = lerpColor('#86efac', '#14532d', Math.random());
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
color = '#555';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lift plant slightly to sit ON shelf, not IN it
|
|
||||||
return (
|
|
||||||
<Instance
|
|
||||||
key={pos.id || i}
|
|
||||||
position={[pos.x, pos.y + 0.3, pos.z]}
|
|
||||||
color={color}
|
|
||||||
scale={[1.2, 1.2, 1.2]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Instances>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty Slots - Refined */}
|
|
||||||
{emptySlots.length > 0 && visMode === 'STANDARD' && (
|
|
||||||
<Instances range={emptySlots.length}>
|
|
||||||
<cylinderGeometry args={[0.05, 0.05, 0.1, 8]} />
|
|
||||||
<meshStandardMaterial color="#4b5563" roughness={0.8} />
|
|
||||||
{emptySlots.map((pos, i) => (
|
|
||||||
<Instance
|
|
||||||
key={pos.id || i}
|
|
||||||
position={[pos.x, pos.y, pos.z]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Instances>
|
|
||||||
)}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FacilityScene({ data, onSelectPlant, targetView, setControls, visMode }: {
|
|
||||||
data: Floor3DData,
|
|
||||||
onSelectPlant: (p: any) => void,
|
|
||||||
targetView: { x: number, y: number, z: number, zoom?: boolean } | null,
|
|
||||||
setControls: (c: CameraControls | null) => void,
|
|
||||||
visMode: VisMode
|
|
||||||
}) {
|
|
||||||
const controlsRef = useRef<CameraControls>(null);
|
|
||||||
const [keysPressed, setKeysPressed] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (controlsRef.current) setControls(controlsRef.current);
|
|
||||||
}, [controlsRef, setControls]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (targetView && controlsRef.current) {
|
|
||||||
const { x, y, z, zoom } = targetView;
|
|
||||||
const dist = zoom ? 15 : 40;
|
|
||||||
const height = zoom ? 15 : 40;
|
|
||||||
controlsRef.current.setLookAt(x + dist, height, z + dist, x, 0, z, true);
|
|
||||||
}
|
|
||||||
}, [targetView]);
|
|
||||||
|
|
||||||
// RESTORED: Keyboard Event Listeners for WASD
|
|
||||||
useEffect(() => {
|
|
||||||
const handleDown = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: true }));
|
|
||||||
const handleUp = (e: KeyboardEvent) => setKeysPressed(k => ({ ...k, [e.code]: false }));
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleDown);
|
|
||||||
window.addEventListener('keyup', handleUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleDown);
|
|
||||||
window.removeEventListener('keyup', handleUp);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
|
||||||
if (!controlsRef.current) return;
|
|
||||||
const speed = 20 * delta;
|
|
||||||
if (keysPressed['KeyW'] || keysPressed['ArrowUp']) controlsRef.current.forward(speed, true);
|
|
||||||
if (keysPressed['KeyS'] || keysPressed['ArrowDown']) controlsRef.current.forward(-speed, true);
|
|
||||||
if (keysPressed['KeyA'] || keysPressed['ArrowLeft']) controlsRef.current.truck(-speed, 0, true);
|
|
||||||
if (keysPressed['KeyD'] || keysPressed['ArrowRight']) controlsRef.current.truck(speed, 0, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const roomMeshes = useMemo(() => {
|
|
||||||
return data.rooms.map(room => {
|
|
||||||
const env = getMockRoomEnv(room.name);
|
|
||||||
|
|
||||||
// Determine floor color based on Env Visualization Mode
|
|
||||||
let floorColor = COLORS.ROOM_FLOOR;
|
|
||||||
let showLabel = true;
|
|
||||||
|
|
||||||
if (visMode === 'TEMP') {
|
|
||||||
// Map 65-85F range to Blue->Green->Red
|
|
||||||
const t = (env.temp - 65) / 20;
|
|
||||||
if (t < 0.5) floorColor = lerpColor(COLORS.TEMP_LOW, COLORS.TEMP_OPTIMAL, t * 2);
|
|
||||||
else floorColor = lerpColor(COLORS.TEMP_OPTIMAL, COLORS.TEMP_HIGH, (t - 0.5) * 2);
|
|
||||||
} else if (visMode === 'HUMIDITY') {
|
|
||||||
// Map 40-80% range to Amber->Blue
|
|
||||||
const t = (env.humidity - 40) / 40;
|
|
||||||
if (t < 0.5) floorColor = lerpColor(COLORS.HUMIDITY_DRY, COLORS.HUMIDITY_OPTIMAL, t * 2);
|
|
||||||
else floorColor = lerpColor(COLORS.HUMIDITY_OPTIMAL, COLORS.HUMIDITY_WET, (t - 0.5) * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group key={room.id} position={[room.posX, 0, room.posY]}>
|
|
||||||
<Text
|
|
||||||
position={[room.width / 2, 0.1, room.height / 2]}
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
fontSize={Math.min(room.width, room.height) / 5}
|
|
||||||
color="white"
|
|
||||||
fillOpacity={0.8}
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="middle"
|
|
||||||
>
|
|
||||||
{showLabel ? room.name : ''}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Env Data Label Overlay - INCREASED SIZE significantly */}
|
|
||||||
{(visMode === 'TEMP' || visMode === 'HUMIDITY') && (
|
|
||||||
<Text
|
|
||||||
position={[room.width / 2, 5, room.height / 2]}
|
|
||||||
rotation={[-Math.PI / 4, 0, 0]}
|
|
||||||
fontSize={6} // Increased from 2 to 6 for readability
|
|
||||||
color="white"
|
|
||||||
outlineColor="#000"
|
|
||||||
outlineWidth={0.2}
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="bottom"
|
|
||||||
>
|
|
||||||
{visMode === 'TEMP' ? `${env.temp.toFixed(1)}°F` : `${env.humidity.toFixed(0)}% RH`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<mesh
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
position={[room.width / 2, 0, room.height / 2]}
|
|
||||||
receiveShadow
|
|
||||||
>
|
|
||||||
<planeGeometry args={[room.width, room.height]} />
|
|
||||||
<meshStandardMaterial
|
|
||||||
color={floorColor}
|
|
||||||
roughness={0.9} // Concrete-like
|
|
||||||
metalness={0.1}
|
|
||||||
transparent={visMode !== 'STANDARD'}
|
|
||||||
opacity={visMode !== 'STANDARD' ? 0.8 : 1}
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
<lineSegments position={[room.width / 2, 0.1, room.height / 2]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
||||||
<edgesGeometry args={[new THREE.PlaneGeometry(room.width, room.height)]} />
|
|
||||||
<lineBasicMaterial color="#4b5563" linewidth={2} />
|
|
||||||
</lineSegments>
|
|
||||||
|
|
||||||
{room.sections.map(section => {
|
|
||||||
const positions = section.positions.map(pos => {
|
|
||||||
const spacing = 0.5;
|
|
||||||
const x = section.posX + (pos.column * spacing);
|
|
||||||
const z = section.posY + (pos.row * spacing);
|
|
||||||
const y = 0.5 + (pos.tier * 0.8); // Increased vertical spacing for tiers
|
|
||||||
return { ...pos, x, y, z };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate visual bounds for labels
|
|
||||||
const distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b);
|
|
||||||
const distinctCols = [...new Set(positions.map(p => p.column))].sort((a, b) => a - b);
|
|
||||||
const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
const spacing = 0.5;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group key={section.id}>
|
|
||||||
{/* Section Label */}
|
|
||||||
<Text
|
|
||||||
position={[section.posX + (section.width / 2), 4, section.posY + (section.height / 2)]}
|
|
||||||
fontSize={1.2}
|
|
||||||
color="#9ca3af"
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="bottom"
|
|
||||||
>
|
|
||||||
{visMode === 'STANDARD' ? section.code : ''}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* VISUAL CUES: Shelves per Tier */}
|
|
||||||
{distinctTiers.map(tier => (
|
|
||||||
<mesh
|
|
||||||
key={`shelf-${tier}`}
|
|
||||||
position={[
|
|
||||||
section.posX + (section.width / 2),
|
|
||||||
0.5 + (tier * 0.8) - 0.1, // Just below the plant base
|
|
||||||
section.posY + (section.height / 2)
|
|
||||||
]}
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
castShadow
|
|
||||||
receiveShadow
|
|
||||||
>
|
|
||||||
{/* Shelf - Lighter for visibility */}
|
|
||||||
<planeGeometry args={[section.width, section.height]} />
|
|
||||||
<meshStandardMaterial color="#94a3b8" roughness={0.5} metalness={0.6} side={THREE.DoubleSide} />
|
|
||||||
</mesh>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* ROW LABELS (Along Z-axis) */}
|
|
||||||
{visMode === 'STANDARD' && distinctRows.map(row => (
|
|
||||||
<Text
|
|
||||||
key={`lbl-row-${row}`}
|
|
||||||
position={[
|
|
||||||
section.posX - 0.4,
|
|
||||||
1,
|
|
||||||
section.posY + (row * spacing)
|
|
||||||
]}
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
fontSize={0.25}
|
|
||||||
color="#6b7280"
|
|
||||||
anchorX="right"
|
|
||||||
anchorY="middle"
|
|
||||||
>
|
|
||||||
R{row}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* COLUMN LABELS (Along X-axis) */}
|
|
||||||
{visMode === 'STANDARD' && distinctCols.map(col => (
|
|
||||||
<Text
|
|
||||||
key={`lbl-col-${col}`}
|
|
||||||
position={[
|
|
||||||
section.posX + (col * spacing),
|
|
||||||
0.2, // On floor
|
|
||||||
section.posY + section.height + 0.3
|
|
||||||
]}
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
fontSize={0.25}
|
|
||||||
color="#6b7280"
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="top"
|
|
||||||
>
|
|
||||||
C{col}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<PlantInstances positions={positions} onPlantClick={onSelectPlant} visMode={visMode} />
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [data, onSelectPlant, visMode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Environment preset="city" background={false} />
|
|
||||||
<ambientLight intensity={0.8} /> {/* Increased brightness */}
|
|
||||||
<directionalLight
|
|
||||||
position={[-10, 50, -10]}
|
|
||||||
intensity={1}
|
|
||||||
castShadow
|
|
||||||
shadow-mapSize={[2048, 2048]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<group position={[-data.floor.width / 2, 0, -data.floor.height / 2]}>
|
|
||||||
{roomMeshes}
|
|
||||||
</group>
|
|
||||||
|
|
||||||
<ContactShadows
|
|
||||||
position={[0, -0.01, 0]}
|
|
||||||
opacity={0.4}
|
|
||||||
scale={100}
|
|
||||||
blur={2.5}
|
|
||||||
far={10}
|
|
||||||
resolution={512}
|
|
||||||
color="#000000"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CameraControls
|
|
||||||
ref={controlsRef}
|
|
||||||
minDistance={5}
|
|
||||||
maxDistance={150}
|
|
||||||
dollySpeed={1}
|
|
||||||
truckSpeed={2}
|
|
||||||
infinityDolly={false}
|
|
||||||
makeDefault
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Facility3DViewerPage() {
|
export default function Facility3DViewerPage() {
|
||||||
const [status, setStatus] = useState('Initializing...');
|
const [status, setStatus] = useState('Initializing...');
|
||||||
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
||||||
const [selectedPlant, setSelectedPlant] = useState<any | null>(null);
|
const [selectedPlant, setSelectedPlant] = useState<PlantPosition | null>(null);
|
||||||
const [targetView, setTargetView] = useState<{ x: number, y: number, z: number, zoom?: boolean } | null>(null);
|
const [targetView, setTargetView] = useState<{ x: number; y: number; z: number; zoom?: boolean } | null>(null);
|
||||||
const [controls, setControls] = useState<CameraControls | null>(null);
|
const [_controls, setControls] = useState<CameraControls | null>(null);
|
||||||
const [visMode, setVisMode] = useState<VisMode>('STANDARD');
|
const [visMode, setVisMode] = useState<VisMode>('STANDARD');
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const targetedPlantTag = searchParams.get('plant');
|
const targetedPlantTag = searchParams.get('plant');
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, []);
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (floorData && targetedPlantTag) {
|
if (floorData && targetedPlantTag) {
|
||||||
let foundPlant: any = null;
|
|
||||||
let foundRoom: any = null;
|
|
||||||
for (const room of floorData.rooms) {
|
for (const room of floorData.rooms) {
|
||||||
for (const section of room.sections) {
|
for (const section of room.sections) {
|
||||||
const match = section.positions.find(p => p.plant?.tagNumber === targetedPlantTag);
|
const match = section.positions.find((p: Position3D) => p.plant?.tagNumber === targetedPlantTag);
|
||||||
if (match) {
|
if (match) {
|
||||||
foundPlant = match;
|
const spacing = 0.5;
|
||||||
foundRoom = room;
|
const plant: PlantPosition = {
|
||||||
break;
|
...match,
|
||||||
|
x: room.posX + section.posX + (match.column * spacing) - floorData.floor.width / 2,
|
||||||
|
z: room.posY + section.posY + (match.row * spacing) - floorData.floor.height / 2,
|
||||||
|
y: 0.4 + (match.tier * 0.6),
|
||||||
|
};
|
||||||
|
setSelectedPlant(plant);
|
||||||
|
setTargetView({ x: plant.x, y: 0, z: plant.z, zoom: true });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (foundPlant) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundPlant && foundRoom) {
|
|
||||||
const offsetX = -floorData.floor.width / 2;
|
|
||||||
const offsetZ = -floorData.floor.height / 2;
|
|
||||||
setSelectedPlant(foundPlant);
|
|
||||||
const roomCenterX = foundRoom.posX + (foundRoom.width / 2) + offsetX;
|
|
||||||
const roomCenterZ = foundRoom.posY + (foundRoom.height / 2) + offsetZ;
|
|
||||||
setTargetView({ x: roomCenterX, y: 0, z: roomCenterZ, zoom: true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [floorData, targetedPlantTag]);
|
}, [floorData, targetedPlantTag]);
|
||||||
|
|
@ -463,32 +74,35 @@ export default function Facility3DViewerPage() {
|
||||||
const props = await layoutApi.getProperties();
|
const props = await layoutApi.getProperties();
|
||||||
if (props[0]?.buildings[0]?.floors[0]) {
|
if (props[0]?.buildings[0]?.floors[0]) {
|
||||||
const floorId = props[0].buildings[0].floors[0].id;
|
const floorId = props[0].buildings[0].floors[0].id;
|
||||||
setStatus('Fetching 3D assets...');
|
setStatus('Fetching 3D scene...');
|
||||||
const data = await layoutApi.getFloor3D(floorId);
|
const data = await layoutApi.getFloor3D(floorId);
|
||||||
setFloorData(data);
|
setFloorData(data);
|
||||||
setStatus('');
|
setStatus('');
|
||||||
} else {
|
} else {
|
||||||
setStatus('No floor layout found');
|
setStatus('No floor layout found. Set up a facility first.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('Error: ' + (err as Error).message);
|
setStatus('Error: ' + (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusRoom = (room: any) => {
|
const focusRoom = (room: Room3D) => {
|
||||||
if (!floorData) return;
|
if (!floorData) return;
|
||||||
const offsetX = -floorData.floor.width / 2;
|
const offsetX = -floorData.floor.width / 2;
|
||||||
const offsetZ = -floorData.floor.height / 2;
|
const offsetZ = -floorData.floor.height / 2;
|
||||||
const centerX = room.posX + (room.width / 2) + offsetX;
|
setTargetView({
|
||||||
const centerZ = room.posY + (room.height / 2) + offsetZ;
|
x: room.posX + room.width / 2 + offsetX,
|
||||||
setTargetView({ x: centerX, y: 0, z: centerZ, zoom: true });
|
y: 0,
|
||||||
|
z: room.posY + room.height / 2 + offsetZ,
|
||||||
|
zoom: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetView = () => setTargetView({ x: 0, y: 0, z: 0, zoom: false });
|
const resetView = () => setTargetView({ x: 0, y: 0, z: 0, zoom: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full relative bg-gray-950 text-white overflow-hidden font-sans">
|
<div className="h-screen w-full relative bg-slate-950 text-white overflow-hidden font-sans">
|
||||||
{/* Header Overlay */}
|
{/* 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-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">
|
<div className="pointer-events-auto flex items-center gap-4">
|
||||||
<Link to="/metrc" className="btn btn-ghost btn-sm text-white">
|
<Link to="/metrc" className="btn btn-ghost btn-sm text-white">
|
||||||
|
|
@ -496,67 +110,67 @@ export default function Facility3DViewerPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
Facility Viewer 3D
|
Facility 3D
|
||||||
<span className="badge badge-accent text-xs">BETA</span>
|
<span className="badge badge-accent text-xs">BETA</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-slate-400">
|
||||||
{floorData ? `${floorData.floor.name} • ${floorData.stats.occupiedPositions} Plants` : 'Loading...'}
|
{floorData ? `${floorData.floor.name} • ${floorData.stats.occupiedPositions} Plants` : 'Loading...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend - Dynamic based on Mode */}
|
{/* Legend */}
|
||||||
<div className="flex gap-4 text-xs bg-black/60 p-2 rounded-lg backdrop-blur border border-white/10 pointer-events-auto shadow-lg">
|
<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' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#4ade80]"></div> Veg</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.VEG }} /> Veg</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#a855f7]"></div> Flower</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.FLOWER }} /> Flower</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> Dry</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.DRY }} /> Dry</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{visMode === 'HEALTH' && (
|
{visMode === 'HEALTH' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#22c55e]"></div> Good</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_GOOD }} /> Good</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#eab308]"></div> Check</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HEALTH_WARN }} /> Check</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#ef4444]"></div> 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' && (
|
{visMode === 'TEMP' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#3b82f6]"></div> <65°F</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_LOW }} /> <65°F</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#22c55e]"></div> 75°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 bg-[#ef4444]"></div> >85°F</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.TEMP_HIGH }} /> >85°F</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{visMode === 'HUMIDITY' && (
|
{visMode === 'HUMIDITY' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#f59e0b]"></div> <40%</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_DRY }} /> <40%</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#0ea5e9]"></div> 60%</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 bg-[#3b82f6]"></div> >80%</div>
|
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full" style={{ background: COLORS.HUMIDITY_WET }} /> >80%</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Nav Sidebar (Standard) */}
|
{/* Room Navigation Sidebar */}
|
||||||
{floorData && (
|
{floorData && (
|
||||||
<div className="absolute top-24 left-4 z-10 w-48 space-y-2 pointer-events-auto animate-in slide-in-from-left-4">
|
<div className="absolute top-24 left-4 z-10 w-44 pointer-events-auto">
|
||||||
<div className="bg-black/40 backdrop-blur rounded-lg p-3 border border-gray-800">
|
<div className="bg-black/50 backdrop-blur rounded-lg p-3 border border-slate-700">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">Navigation</span>
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Rooms</span>
|
||||||
<button onClick={resetView} className="hover:text-accent transition-colors">
|
<button onClick={resetView} className="hover:text-accent transition-colors" title="Reset View">
|
||||||
<Maximize size={14} />
|
<Maximize size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-[40vh] overflow-y-auto">
|
<div className="space-y-1 max-h-[45vh] overflow-y-auto">
|
||||||
{floorData.rooms.map(room => (
|
{floorData.rooms.map((room: Room3D) => (
|
||||||
<button
|
<button
|
||||||
key={room.id}
|
key={room.id}
|
||||||
onClick={() => focusRoom(room)}
|
onClick={() => focusRoom(room)}
|
||||||
className="w-full text-left text-sm px-2 py-1.5 rounded hover:bg-gray-800 transition-colors flex items-center justify-between group"
|
className="w-full text-left text-sm px-2 py-1.5 rounded hover:bg-slate-800 transition-colors flex items-center justify-between group"
|
||||||
>
|
>
|
||||||
<span className="truncate">{room.name}</span>
|
<span className="truncate">{room.name}</span>
|
||||||
<MousePointer2 size={12} className="opacity-0 group-hover:opacity-100 text-accent transition-opacity" />
|
<MousePointer2 size={12} className="opacity-0 group-hover:opacity-100 text-accent" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -564,61 +178,36 @@ export default function Facility3DViewerPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data Vis Controls (Floating Right) */}
|
{/* Visualization Mode Buttons */}
|
||||||
<div className="absolute top-24 right-4 z-10 w-16 flex flex-col gap-2 pointer-events-auto animate-in slide-in-from-right-4">
|
<div className="absolute top-24 right-4 z-10 w-14 flex flex-col gap-2 pointer-events-auto">
|
||||||
<button
|
{[
|
||||||
onClick={() => setVisMode('STANDARD')}
|
{ mode: 'STANDARD' as VisMode, icon: Layers, label: 'Layout', active: 'bg-primary' },
|
||||||
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'STANDARD' ? 'bg-primary/90 border-primary text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
|
{ mode: 'HEALTH' as VisMode, icon: Activity, label: 'Health', active: 'bg-red-500' },
|
||||||
title="Standard View"
|
{ mode: 'TEMP' as VisMode, icon: Thermometer, label: 'Temp', active: 'bg-orange-500' },
|
||||||
>
|
{ mode: 'HUMIDITY' as VisMode, icon: Droplets, label: 'VPD', active: 'bg-blue-500' },
|
||||||
<Layers size={24} className="mx-auto" />
|
{ mode: 'YIELD' as VisMode, icon: Leaf, label: 'Yield', active: 'bg-green-600' },
|
||||||
<span className="text-[10px] block text-center mt-1">Layout</span>
|
].map(({ mode, icon: Icon, label, active }) => (
|
||||||
</button>
|
<button
|
||||||
|
key={mode}
|
||||||
<button
|
onClick={() => setVisMode(mode)}
|
||||||
onClick={() => setVisMode('HEALTH')}
|
className={`p-2.5 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === mode
|
||||||
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'HEALTH' ? 'bg-red-500/90 border-red-400 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
|
? `${active} border-white/30 text-white scale-105`
|
||||||
title="Plant Health"
|
: 'bg-black/50 border-white/10 text-slate-400 hover:bg-black/70'
|
||||||
>
|
}`}
|
||||||
<Activity size={24} className="mx-auto" />
|
title={label}
|
||||||
<span className="text-[10px] block text-center mt-1">Health</span>
|
>
|
||||||
</button>
|
<Icon size={22} className="mx-auto" />
|
||||||
|
<span className="text-[9px] block text-center mt-0.5">{label}</span>
|
||||||
<button
|
</button>
|
||||||
onClick={() => setVisMode('TEMP')}
|
))}
|
||||||
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'TEMP' ? 'bg-orange-500/90 border-orange-400 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
|
|
||||||
title="Temperature Map"
|
|
||||||
>
|
|
||||||
<Thermometer size={24} className="mx-auto" />
|
|
||||||
<span className="text-[10px] block text-center mt-1">Temp</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setVisMode('HUMIDITY')}
|
|
||||||
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'HUMIDITY' ? 'bg-blue-500/90 border-blue-400 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
|
|
||||||
title="Humidity Map"
|
|
||||||
>
|
|
||||||
<Droplets size={24} className="mx-auto" />
|
|
||||||
<span className="text-[10px] block text-center mt-1">VPD</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setVisMode('YIELD')}
|
|
||||||
className={`p-3 rounded-xl backdrop-blur-md shadow-lg transition-all border ${visMode === 'YIELD' ? 'bg-green-600/90 border-green-500 text-white scale-110' : 'bg-black/40 border-white/10 text-gray-400 hover:bg-black/60'}`}
|
|
||||||
title="Yield Forecast"
|
|
||||||
>
|
|
||||||
<Leaf size={24} className="mx-auto" />
|
|
||||||
<span className="text-[10px] block text-center mt-1">Yield</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading/Error Overlay */}
|
||||||
{/* Error/Status Overlay */}
|
|
||||||
{status && (
|
{status && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-50 bg-black/80 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">
|
||||||
<Loader2 size={32} className="animate-spin text-accent mx-auto mb-2" />
|
<Loader2 size={36} className="animate-spin text-accent mx-auto mb-3" />
|
||||||
<p className="text-gray-300">{status}</p>
|
<p className="text-slate-300">{status}</p>
|
||||||
{status.includes('Error') && (
|
{status.includes('Error') && (
|
||||||
<button onClick={() => window.location.reload()} className="btn btn-sm btn-outline mt-4">
|
<button onClick={() => window.location.reload()} className="btn btn-sm btn-outline mt-4">
|
||||||
Retry
|
Retry
|
||||||
|
|
@ -628,59 +217,56 @@ export default function Facility3DViewerPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selection Overlay */}
|
{/* Plant Selection Panel */}
|
||||||
{selectedPlant && (
|
{selectedPlant?.plant && (
|
||||||
<div className="absolute bottom-4 right-4 z-20 w-80 bg-gray-900/90 border border-gray-700 rounded-lg p-4 shadow-xl backdrop-blur-md animate-in slide-in-from-right-10 pointer-events-auto">
|
<div className="absolute bottom-4 right-4 z-20 w-72 bg-slate-900/95 border border-slate-700 rounded-lg p-4 shadow-xl backdrop-blur-md pointer-events-auto">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs uppercase tracking-wider text-gray-500">{selectedPlant.plant.metrcTag ? 'METRC TAG' : 'PLANT ID'}</span>
|
<span className="text-[10px] uppercase tracking-wider text-slate-500">
|
||||||
<h3 className="font-bold text-accent text-lg">{selectedPlant.plant.tagNumber}</h3>
|
{selectedPlant.plant.batchId ? 'METRC TAG' : 'PLANT ID'}
|
||||||
|
</span>
|
||||||
|
<h3 className="font-bold text-accent text-lg leading-tight">{selectedPlant.plant.tagNumber}</h3>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setSelectedPlant(null)} className="text-gray-400 hover:text-white">×</button>
|
<button onClick={() => setSelectedPlant(null)} className="text-slate-400 hover:text-white text-lg">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="bg-black/30 p-2 rounded">
|
<div className="bg-black/30 p-2 rounded">
|
||||||
<span className="text-gray-400 text-xs block">Strain</span>
|
<span className="text-slate-500 text-xs block">Strain</span>
|
||||||
<span className="font-medium text-white">{selectedPlant.plant.strain || 'Unknown'}</span>
|
<span className="font-medium">{selectedPlant.plant.strain || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-black/30 p-2 rounded">
|
<div className="bg-black/30 p-2 rounded">
|
||||||
<span className="text-gray-400 text-xs block">Stage</span>
|
<span className="text-slate-500 text-xs block">Stage</span>
|
||||||
<span className={`badge badge-xs ${selectedPlant.plant.stage === 'FLOWER' ? 'badge-primary' : 'badge-accent'}`}>
|
<span className={`badge badge-xs ${selectedPlant.plant.stage === 'FLOWER' ? 'badge-primary' : 'badge-accent'}`}>
|
||||||
{selectedPlant.plant.stage || 'N/A'}
|
{selectedPlant.plant.stage || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-sm mt-4 border-t border-gray-700 pt-4">
|
<div className="mt-3 pt-3 border-t border-slate-700 text-sm space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-400">Batch</span>
|
<span className="text-slate-500">Batch</span>
|
||||||
<span className="text-right">{selectedPlant.plant.batchName || '-'}</span>
|
<span>{selectedPlant.plant.batchName || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-400">Position</span>
|
<span className="text-slate-500">Position</span>
|
||||||
<span className="font-mono text-xs text-right">R{selectedPlant.row} • C{selectedPlant.column} • T{selectedPlant.tier}</span>
|
<span className="font-mono text-xs">R{selectedPlant.row} • C{selectedPlant.column} • T{selectedPlant.tier}</span>
|
||||||
</div>
|
</div>
|
||||||
{visMode === 'HEALTH' && (
|
|
||||||
<div className="flex justify-between items-center mt-2 bg-red-900/20 p-2 rounded border border-red-900/50">
|
|
||||||
<span className="text-red-400 flex items-center gap-2"><Activity size={14} /> Risk Factor</span>
|
|
||||||
<span className="text-red-300 font-bold">High</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Canvas camera={{ position: [20, 30, 20], fov: 60 }} shadows>
|
{/* 3D Canvas */}
|
||||||
|
<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>}>
|
||||||
{floorData && (
|
{floorData && (
|
||||||
<FacilityScene
|
<FacilityScene
|
||||||
data={floorData}
|
data={floorData}
|
||||||
onSelectPlant={setSelectedPlant}
|
|
||||||
targetView={targetView}
|
|
||||||
setControls={setControls}
|
|
||||||
visMode={visMode}
|
visMode={visMode}
|
||||||
|
targetView={targetView}
|
||||||
|
onPlantClick={setSelectedPlant}
|
||||||
|
onControlsReady={setControls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue