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)
This commit is contained in:
parent
3d735d3527
commit
af424a1a1c
5 changed files with 420 additions and 1 deletions
176
src/frontend/App.jsx
Normal file
176
src/frontend/App.jsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
195
src/frontend/OfficeScene.jsx
Normal file
195
src/frontend/OfficeScene.jsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useFrame } from '@react-three/fiber';
|
||||||
|
import { Text, Html } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import useStore, { ZONES } from './store.js';
|
||||||
|
import AgentSprite, { gridToWorld } from './AgentSprite.jsx';
|
||||||
|
|
||||||
|
// ── Tile rendering constants ─────────────────────────────────────
|
||||||
|
const TILE_W = 1.0;
|
||||||
|
const TILE_H = 0.5;
|
||||||
|
const GRID_ROWS = 20;
|
||||||
|
const GRID_COLS = 28;
|
||||||
|
|
||||||
|
// ── Zone colors ──────────────────────────────────────────────────
|
||||||
|
const ZONE_COLORS = {
|
||||||
|
'pod-a': '#C68B3C',
|
||||||
|
'pod-b': '#C68B3C',
|
||||||
|
'solo-1': '#3A7CA5',
|
||||||
|
'solo-2': '#3A7CA5',
|
||||||
|
'coaching': '#5C8A5C',
|
||||||
|
'coffee': '#8B6914',
|
||||||
|
};
|
||||||
|
const FLOOR_COLOR = '#1a1a2e';
|
||||||
|
const GRID_LINE_COLOR = '#2a2a4a';
|
||||||
|
|
||||||
|
// ── Isometric floor tile ─────────────────────────────────────────
|
||||||
|
function FloorTile({ row, col, zoneId }) {
|
||||||
|
const [wx, _, wz] = gridToWorld(row, col);
|
||||||
|
const color = zoneId ? ZONE_COLORS[zoneId] || FLOOR_COLOR : FLOOR_COLOR;
|
||||||
|
|
||||||
|
// Create isometric diamond shape
|
||||||
|
const shape = useMemo(() => {
|
||||||
|
const s = new THREE.Shape();
|
||||||
|
const hw = TILE_W / 2;
|
||||||
|
const hh = TILE_H / 2;
|
||||||
|
s.moveTo(0, hh);
|
||||||
|
s.lineTo(hw, 0);
|
||||||
|
s.lineTo(0, -hh);
|
||||||
|
s.lineTo(-hw, 0);
|
||||||
|
s.closePath();
|
||||||
|
return s;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Floor diamond */}
|
||||||
|
<mesh
|
||||||
|
position={[wx, 0, wz]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<shapeGeometry args={[shape]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Grid line border */}
|
||||||
|
<lineLoop position={[wx, 0.001, wz]}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute
|
||||||
|
attach="attributes-position"
|
||||||
|
count={5}
|
||||||
|
array={new Float32Array([
|
||||||
|
-TILE_W / 2, 0, 0,
|
||||||
|
0, 0, -TILE_H / 2,
|
||||||
|
TILE_W / 2, 0, 0,
|
||||||
|
0, 0, TILE_H / 2,
|
||||||
|
-TILE_W / 2, 0, 0,
|
||||||
|
])}
|
||||||
|
itemSize={3}
|
||||||
|
/>
|
||||||
|
</bufferGeometry>
|
||||||
|
<lineBasicMaterial color={GRID_LINE_COLOR} transparent opacity={0.3} />
|
||||||
|
</lineLoop>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zone label overlay ──────────────────────────────────────────
|
||||||
|
function ZoneLabel({ zone }) {
|
||||||
|
const centerRow = zone.bounds.row + zone.bounds.rows / 2;
|
||||||
|
const centerCol = zone.bounds.col + zone.bounds.cols / 2;
|
||||||
|
const [wx, , wz] = gridToWorld(centerRow, centerCol);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html position={[wx, 0.5, wz]} center distanceFactor={30} style={{ pointerEvents: 'none' }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: '#ffffffcc',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: `1px solid ${zone.color}44`,
|
||||||
|
}}>
|
||||||
|
{zone.name}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection indicator ────────────────────────────────────────
|
||||||
|
function ConnectionIndicator() {
|
||||||
|
const connected = useStore(s => s.wsConnected);
|
||||||
|
const reconnecting = useStore(s => s.wsReconnecting);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html fullscreen style={{ pointerEvents: 'none' }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 20,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#ccc',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444',
|
||||||
|
boxShadow: `0 0 6px ${connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444'}`,
|
||||||
|
}} />
|
||||||
|
{connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scene ──────────────────────────────────────────────────
|
||||||
|
export default function OfficeScene() {
|
||||||
|
const agents = useStore(s => s.agents);
|
||||||
|
const grid = useStore(s => s.grid);
|
||||||
|
|
||||||
|
// Hover state: { [agentId]: boolean }
|
||||||
|
const [hoveredAgents, setHoveredAgents] = useState({});
|
||||||
|
|
||||||
|
const tiles = useMemo(() => {
|
||||||
|
const result = [];
|
||||||
|
for (let row = 0; row < GRID_ROWS; row++) {
|
||||||
|
for (let col = 0; col < GRID_COLS; col++) {
|
||||||
|
const tile = grid.tiles?.[row]?.[col];
|
||||||
|
result.push({ row, col, zoneId: tile?.zoneId || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [grid]);
|
||||||
|
|
||||||
|
const agentList = useMemo(() => Object.values(agents), [agents]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Base ground plane */}
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
|
||||||
|
<planeGeometry args={[30, 20]} />
|
||||||
|
<meshStandardMaterial color="#111122" roughness={1} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Floor tiles */}
|
||||||
|
{tiles.map(({ row, col, zoneId }) => (
|
||||||
|
<FloorTile key={`${row}-${col}`} row={row} col={col} zoneId={zoneId} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Zone labels */}
|
||||||
|
{Object.values(ZONES).map(zone => (
|
||||||
|
<ZoneLabel key={zone.id} zone={zone} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Agent sprites */}
|
||||||
|
{agentList.map(agent => (
|
||||||
|
<AgentSprite
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
hovered={!!hoveredAgents[agent.id]}
|
||||||
|
onHover={(id, isHovered) =>
|
||||||
|
setHoveredAgents(prev => ({ ...prev, [id]: isHovered }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<ConnectionIndicator />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,6 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="./main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
26
src/frontend/package.json
Normal file
26
src/frontend/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "spatial-office-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 18790",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^9.117.0",
|
||||||
|
"@react-three/fiber": "^8.17.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"three": "^0.170.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/frontend/vite.config.js
Normal file
22
src/frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: '.',
|
||||||
|
base: '/',
|
||||||
|
server: {
|
||||||
|
port: 18790,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue