spatial-office/src/frontend/App.jsx
Engineering (agent-engineering) af424a1a1c feat(frontend): isometric office renderer with Three.js
- React + @react-three/fiber + Zustand frontend
- Isometric tile grid with 6 colored zones
- Agent sprites as colored cubes with glow effects
- Smooth movement animation via lerp
- Zone labels (HTML overlay)
- Hover tooltips with agent activity/task
- WebSocket client with reconnect backoff
- Mock mode for standalone operation (no backend needed)
- Orthographic camera setup for true isometric projection
- Vite build configuration (port 18790)
2026-05-19 16:26:54 -07:00

176 lines
5.1 KiB
JavaScript

import React, { useEffect, useRef } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import * as THREE from 'three';
import useStore from './store.js';
import wsClient from './WebSocketClient.js';
import OfficeScene from './OfficeScene.jsx';
// ── Camera setup for true isometric ─────────────────────────────
// Orthographic with rotation: 45° around Y, then ~35.264° tilt down (atan(1/√2))
// But the React-idiomatic way: position the camera and use lookAt
// Simpler: use orbit controls locked to isometric-ish angle
function CameraSetup() {
// We use OrbitControls with limited rotation for a fixed-ish isometric feel
return null; // Controls are in the Canvas
}
// ── UI Overlay ──────────────────────────────────────────────────
function UIOverlay() {
const agents = useStore(s => s.agents);
const connected = useStore(s => s.wsConnected);
return (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
padding: '0 16px',
fontSize: 11,
color: '#999',
borderTop: '1px solid #333',
zIndex: 10,
}}>
<span>Spatial Office</span>
<span>|</span>
<span>
<span style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: connected ? '#44cc44' : '#ff4444',
marginRight: 4,
verticalAlign: 'middle',
}} />
{connected ? 'Live' : 'Offline'}
</span>
<span>|</span>
<span>{Object.keys(agents).length} agents</span>
</div>
);
}
// ── App ─────────────────────────────────────────────────────────
export default function App() {
const hasConnected = useRef(false);
useEffect(() => {
if (hasConnected.current) return;
hasConnected.current = true;
// Try to connect to WebSocket backend
wsClient.connect();
// After 2 seconds, if no backend, start mock mode
const mockTimer = setTimeout(() => {
const state = useStore.getState();
if (!state.wsConnected) {
console.log('[App] No backend found, starting mock mode');
wsClient.startMock();
}
}, 2000);
return () => {
clearTimeout(mockTimer);
wsClient.disconnect();
};
}, []);
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<Canvas
shadows
gl={{
antialias: true,
toneMapping: THREE.ACESFilmicToneMapping,
toneMappingExposure: 1.2,
}}
camera={{
position: [18, 20, 18],
near: 0.1,
far: 200,
}}
style={{ background: '#1a1a2e' }}
>
{/* Orthographic camera for true isometric */}
<OrthoCamera />
{/* Lighting */}
<ambientLight intensity={0.4} />
<directionalLight
position={[10, 20, 5]}
intensity={0.8}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<hemisphereLight
args={['#8ec8f2', '#332211', 0.3]}
/>
{/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */}
<OrbitControls
enableRotate={true}
enableZoom={true}
enablePan={true}
minZoom={5}
maxZoom={40}
maxPolarAngle={Math.PI / 2.2}
dampingFactor={0.1}
target={[0, 0, 6]}
/>
{/* Office scene */}
<OfficeScene />
</Canvas>
<UIOverlay />
</div>
);
}
// ── Ortho camera wrapper ────────────────────────────────────────
function OrthoCamera() {
const ref = useRef();
useEffect(() => {
if (!ref.current) return;
// On mount, set orthographic projection
const camera = ref.current;
const aspect = window.innerWidth / window.innerHeight;
const frustumSize = 18;
camera.left = -frustumSize * aspect / 2;
camera.right = frustumSize * aspect / 2;
camera.top = frustumSize / 2;
camera.bottom = -frustumSize / 2;
camera.near = 0.1;
camera.far = 200;
camera.position.set(14, 18, 14);
camera.lookAt(0, 0, 5);
camera.updateProjectionMatrix();
// Handle resize
const onResize = () => {
const a = window.innerWidth / window.innerHeight;
camera.left = -frustumSize * a / 2;
camera.right = frustumSize * a / 2;
camera.top = frustumSize / 2;
camera.bottom = -frustumSize / 2;
camera.updateProjectionMatrix();
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return <orthographicCamera ref={ref} />;
}