diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx new file mode 100644 index 0000000..0da2cf5 --- /dev/null +++ b/src/frontend/App.jsx @@ -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 ( +
+ Spatial Office + | + + + {connected ? 'Live' : 'Offline'} + + | + {Object.keys(agents).length} agents +
+ ); +} + +// ── 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 ( +
+ + {/* Orthographic camera for true isometric */} + + + {/* Lighting */} + + + + + {/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */} + + + {/* Office scene */} + + + + +
+ ); +} + +// ── 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 ; +} diff --git a/src/frontend/OfficeScene.jsx b/src/frontend/OfficeScene.jsx new file mode 100644 index 0000000..6ef9cd6 --- /dev/null +++ b/src/frontend/OfficeScene.jsx @@ -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 ( + + {/* Floor diamond */} + + + + + + {/* Grid line border */} + + + + + + + + ); +} + +// ── 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 ( + +
+ {zone.name} +
+ + ); +} + +// ── Connection indicator ──────────────────────────────────────── +function ConnectionIndicator() { + const connected = useStore(s => s.wsConnected); + const reconnecting = useStore(s => s.wsReconnecting); + + return ( + +
+
+ {connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'} +
+ + ); +} + +// ── 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 ( + + {/* Base ground plane */} + + + + + + {/* Floor tiles */} + {tiles.map(({ row, col, zoneId }) => ( + + ))} + + {/* Zone labels */} + {Object.values(ZONES).map(zone => ( + + ))} + + {/* Agent sprites */} + {agentList.map(agent => ( + + setHoveredAgents(prev => ({ ...prev, [id]: isHovered })) + } + /> + ))} + + {/* Connection status */} + + + ); +} diff --git a/src/frontend/index.html b/src/frontend/index.html index 08bcbf3..843ef69 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -35,6 +35,6 @@
- + diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..2985ed0 --- /dev/null +++ b/src/frontend/package.json @@ -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" + } +} diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js new file mode 100644 index 0000000..5af3800 --- /dev/null +++ b/src/frontend/vite.config.js @@ -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'), + }, + }, +});