watercooler
Version:
A beautiful 3D visualization of your mailbox messages as a village of coworkers
1,494 lines (1,261 loc) • 58.8 kB
JavaScript
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
// State
let config = { user: '', mailbox: '', avatar: null };
let messages = []; // Messages TO user (for main panel)
let allMessages = []; // All messages involving user (for desk dialogs)
let recipients = [];
let statusStates = {}; // Map of name -> {tool_name, timestamp}
let scene, camera, renderer, controls;
let agentMeshes = new Map();
let connectionLines = [];
let raycaster, mouse;
// Color palette for agents - modern muted tones
const agentColors = [
0x5EEAD4, 0x6EE7B7, 0x7DD3FC, 0xA78BFA, 0xFBBF24,
0xF9A8D4, 0x86EFAC, 0x93C5FD, 0xC4B5FD, 0x67E8F9
];
function getAgentColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return agentColors[Math.abs(hash) % agentColors.length];
}
// Platform dimensions
const PLATFORM_SIZE = 60;
const PLATFORM_HEIGHT = 2;
const WALL_HEIGHT = 18;
// Animated objects
let holoSphere = null;
let holoParticles = null;
let glowLights = [];
let floatingParticles = [];
let composer = null;
let waterMesh = null;
// Initialize Three.js
function init() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a3a3a);
scene.fog = new THREE.FogExp2(0x1a3a3a, 0.003);
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(55, 45, 55);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxPolarAngle = Math.PI / 2 - 0.05;
controls.minDistance = 25;
controls.maxDistance = 120;
controls.enableZoom = true;
controls.zoomSpeed = 0.8;
controls.enablePan = false;
controls.target.set(0, 5, 0);
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN
};
// === Lighting ===
// Soft ambient
const ambientLight = new THREE.AmbientLight(0x2d5a5a, 0.8);
scene.add(ambientLight);
// Main directional light (warm)
const dirLight = new THREE.DirectionalLight(0xfff5e6, 0.6);
dirLight.position.set(40, 80, 30);
dirLight.castShadow = true;
dirLight.shadow.camera.left = -40;
dirLight.shadow.camera.right = 40;
dirLight.shadow.camera.top = 40;
dirLight.shadow.camera.bottom = -40;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.bias = -0.001;
scene.add(dirLight);
// Fill light from below (teal tint)
const fillLight = new THREE.DirectionalLight(0x4fd1c5, 0.3);
fillLight.position.set(-20, 5, -20);
scene.add(fillLight);
// Hemisphere light for natural ambient
const hemiLight = new THREE.HemisphereLight(0x4fd1c5, 0x1a3a3a, 0.4);
scene.add(hemiLight);
// Additional accent lights for bloom effect
// Center glow from holographic sphere area
const centerGlow = new THREE.PointLight(0x4fd1c5, 0.6, 50);
centerGlow.position.set(0, PLATFORM_HEIGHT + 15, 0);
scene.add(centerGlow);
// Edge accent lights
const edgeLight1 = new THREE.PointLight(0x88ffdd, 0.4, 30);
edgeLight1.position.set(30, PLATFORM_HEIGHT + 10, 30);
scene.add(edgeLight1);
const edgeLight2 = new THREE.PointLight(0x88ffdd, 0.4, 30);
edgeLight2.position.set(-30, PLATFORM_HEIGHT + 10, -30);
scene.add(edgeLight2);
// === Platform ===
createPlatform();
// === Reflective Water Surface ===
createReflectiveWater();
// === Glass Walls ===
createGlassWalls();
// === Decorative Plants ===
createPlants();
// === Holographic Sphere ===
createHolographicSphere();
// === Ambient Glow Lights ===
createGlowLights();
// === Floating Particles ===
createFloatingParticles();
// === Background Stars ===
createBackgroundStars();
window.addEventListener('resize', onWindowResize);
// Raycaster for desk clicks
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click', onDeskClick);
// Add touch support for mobile
renderer.domElement.addEventListener('touchstart', onDeskTouchStart, { passive: false });
renderer.domElement.addEventListener('touchend', onDeskTouchEnd, { passive: false });
// Disable context menu on mobile for better UX
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
// Handle orientation change
window.addEventListener('orientationchange', () => {
setTimeout(onWindowResize, 100);
});
// === Post Processing ===
setupPostProcessing();
animate();
}
function createPlatform() {
// Main platform - dark concrete slab
const platformGeo = new THREE.BoxGeometry(PLATFORM_SIZE, PLATFORM_HEIGHT, PLATFORM_SIZE);
const platformMat = new THREE.MeshStandardMaterial({
color: 0x3a3a3a,
roughness: 0.4,
metalness: 0.1
});
const platform = new THREE.Mesh(platformGeo, platformMat);
platform.position.y = PLATFORM_HEIGHT / 2;
platform.receiveShadow = true;
platform.castShadow = true;
scene.add(platform);
// Edge trim - lighter accent
const trimGeo = new THREE.BoxGeometry(PLATFORM_SIZE + 0.5, 0.3, PLATFORM_SIZE + 0.5);
const trimMat = new THREE.MeshStandardMaterial({
color: 0x5a5a5a,
roughness: 0.3,
metalness: 0.3
});
const trim = new THREE.Mesh(trimGeo, trimMat);
trim.position.y = PLATFORM_HEIGHT + 0.15;
scene.add(trim);
// Floor surface - polished concrete with subtle grid
const floorGeo = new THREE.PlaneGeometry(PLATFORM_SIZE - 2, PLATFORM_SIZE - 2);
const floorMat = new THREE.MeshStandardMaterial({
color: 0x4a4a4a,
roughness: 0.2,
metalness: 0.15
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = PLATFORM_HEIGHT + 0.02;
floor.receiveShadow = true;
scene.add(floor);
// Subtle grid on floor
const gridHelper = new THREE.GridHelper(PLATFORM_SIZE - 4, 20, 0x555555, 0x444444);
gridHelper.position.y = PLATFORM_HEIGHT + 0.05;
gridHelper.material.opacity = 0.15;
gridHelper.material.transparent = true;
scene.add(gridHelper);
// Ground below platform (dark reflection surface)
const groundGeo = new THREE.PlaneGeometry(300, 300);
const groundMat = new THREE.MeshStandardMaterial({
color: 0x1a3a3a,
roughness: 0.6,
metalness: 0.2
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.1;
ground.receiveShadow = true;
scene.add(ground);
}
function createReflectiveWater() {
// Reflective water surface below the platform
const waterSize = PLATFORM_SIZE * 1.5;
const waterGeo = new THREE.PlaneGeometry(waterSize, waterSize, 64, 64);
// Create a custom shader material for reflective water effect
const waterMat = new THREE.MeshPhysicalMaterial({
color: 0x0d3333,
metalness: 0.9,
roughness: 0.1,
transparent: true,
opacity: 0.85,
transmission: 0.3,
thickness: 0.5,
clearcoat: 1.0,
clearcoatRoughness: 0.1,
side: THREE.DoubleSide
});
waterMesh = new THREE.Mesh(waterGeo, waterMat);
waterMesh.rotation.x = -Math.PI / 2;
waterMesh.position.y = -0.5;
waterMesh.receiveShadow = true;
scene.add(waterMesh);
// Add subtle ripple effect using vertex displacement
const positions = waterMesh.geometry.attributes.position;
const initialPositions = positions.array.slice();
waterMesh.userData.initialPositions = initialPositions;
waterMesh.userData.ripplePhase = 0;
}
function setupPostProcessing() {
// Setup EffectComposer for bloom
composer = new EffectComposer(renderer);
// Add render pass
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// Add bloom pass
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.8, // strength
0.4, // radius
0.75 // threshold
);
composer.addPass(bloomPass);
}
function createGlassWalls() {
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0x88cccc,
transparent: true,
opacity: 0.08,
roughness: 0.05,
metalness: 0.0,
transmission: 0.95,
thickness: 0.5,
side: THREE.DoubleSide
});
const wallHeight = WALL_HEIGHT;
const wallY = PLATFORM_HEIGHT + wallHeight / 2;
const halfSize = PLATFORM_SIZE / 2;
// Back wall
const backWall = new THREE.Mesh(
new THREE.PlaneGeometry(PLATFORM_SIZE, wallHeight),
glassMat
);
backWall.position.set(0, wallY, -halfSize);
scene.add(backWall);
// Left wall
const leftWall = new THREE.Mesh(
new THREE.PlaneGeometry(PLATFORM_SIZE, wallHeight),
glassMat
);
leftWall.position.set(-halfSize, wallY, 0);
leftWall.rotation.y = Math.PI / 2;
scene.add(leftWall);
// Right wall (partial, for openness)
const rightWall = new THREE.Mesh(
new THREE.PlaneGeometry(PLATFORM_SIZE, wallHeight),
glassMat
);
rightWall.position.set(halfSize, wallY, 0);
rightWall.rotation.y = -Math.PI / 2;
scene.add(rightWall);
// Glass edge frames (vertical pillars at corners)
const pillarGeo = new THREE.BoxGeometry(0.5, wallHeight, 0.5);
const pillarMat = new THREE.MeshStandardMaterial({
color: 0x777777,
roughness: 0.2,
metalness: 0.6
});
const corners = [
[-halfSize, wallY, -halfSize],
[halfSize, wallY, -halfSize],
[-halfSize, wallY, halfSize],
[halfSize, wallY, halfSize]
];
corners.forEach(pos => {
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
pillar.position.set(...pos);
pillar.castShadow = true;
scene.add(pillar);
});
// Top edge frame
const topFrameMat = new THREE.MeshStandardMaterial({
color: 0x666666,
roughness: 0.2,
metalness: 0.5
});
const frameY = PLATFORM_HEIGHT + wallHeight;
// Back top frame
const backFrame = new THREE.Mesh(new THREE.BoxGeometry(PLATFORM_SIZE, 0.3, 0.3), topFrameMat);
backFrame.position.set(0, frameY, -halfSize);
scene.add(backFrame);
// Left top frame
const leftFrame = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, PLATFORM_SIZE), topFrameMat);
leftFrame.position.set(-halfSize, frameY, 0);
scene.add(leftFrame);
// Right top frame
const rightFrame = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.3, PLATFORM_SIZE), topFrameMat);
rightFrame.position.set(halfSize, frameY, 0);
scene.add(rightFrame);
}
function createPlants() {
const plantPositions = [
// Corner clusters
[-25, PLATFORM_HEIGHT, -25],
[25, PLATFORM_HEIGHT, -25],
[-25, PLATFORM_HEIGHT, 25],
[25, PLATFORM_HEIGHT, 25],
// Edge accents
[-20, PLATFORM_HEIGHT, 27],
[20, PLATFORM_HEIGHT, 27],
[-27, PLATFORM_HEIGHT, 0],
[27, PLATFORM_HEIGHT, -15],
];
plantPositions.forEach(pos => {
createPlantCluster(pos[0], pos[1], pos[2]);
});
}
function createPlantCluster(x, y, z) {
const group = new THREE.Group();
// Planter box
const planterGeo = new THREE.BoxGeometry(3, 1.5, 3);
const planterMat = new THREE.MeshStandardMaterial({
color: 0x2a2a2a,
roughness: 0.6,
metalness: 0.1
});
const planter = new THREE.Mesh(planterGeo, planterMat);
planter.position.y = 0.75;
planter.castShadow = true;
planter.receiveShadow = true;
group.add(planter);
// Soil
const soilGeo = new THREE.BoxGeometry(2.6, 0.2, 2.6);
const soilMat = new THREE.MeshStandardMaterial({ color: 0x3d2817 });
const soil = new THREE.Mesh(soilGeo, soilMat);
soil.position.y = 1.5;
group.add(soil);
// Foliage - multiple spheres for bush look
const leafColors = [0x1a6b3a, 0x228B22, 0x2d8b4e, 0x1f7a3f];
for (let i = 0; i < 5; i++) {
const size = 0.6 + Math.random() * 0.8;
const leafGeo = new THREE.SphereGeometry(size, 8, 8);
const leafMat = new THREE.MeshStandardMaterial({
color: leafColors[Math.floor(Math.random() * leafColors.length)],
roughness: 0.8
});
const leaf = new THREE.Mesh(leafGeo, leafMat);
leaf.position.set(
(Math.random() - 0.5) * 1.5,
1.8 + Math.random() * 1.5,
(Math.random() - 0.5) * 1.5
);
leaf.castShadow = true;
group.add(leaf);
}
// Tall fern-like elements (cone shapes)
for (let i = 0; i < 3; i++) {
const fernGeo = new THREE.ConeGeometry(0.3, 2 + Math.random() * 2, 6);
const fernMat = new THREE.MeshStandardMaterial({
color: 0x1a5c2e,
roughness: 0.7
});
const fern = new THREE.Mesh(fernGeo, fernMat);
fern.position.set(
(Math.random() - 0.5) * 1.5,
2.5 + Math.random() * 1.5,
(Math.random() - 0.5) * 1.5
);
fern.castShadow = true;
group.add(fern);
}
group.position.set(x, y, z);
scene.add(group);
}
function createHolographicSphere() {
// Wireframe sphere
const sphereGeo = new THREE.IcosahedronGeometry(6, 3);
const sphereMat = new THREE.MeshBasicMaterial({
color: 0x4fd1c5,
wireframe: true,
transparent: true,
opacity: 0.3
});
holoSphere = new THREE.Mesh(sphereGeo, sphereMat);
holoSphere.position.set(0, PLATFORM_HEIGHT + 12, 0);
scene.add(holoSphere);
// Inner glow sphere
const innerGeo = new THREE.SphereGeometry(4, 32, 32);
const innerMat = new THREE.MeshBasicMaterial({
color: 0x4fd1c5,
transparent: true,
opacity: 0.05
});
const innerSphere = new THREE.Mesh(innerGeo, innerMat);
holoSphere.add(innerSphere);
// Point cloud on sphere surface
const particleCount = 300;
const positions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 5.5 + Math.random() * 0.5;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
}
const particleGeo = new THREE.BufferGeometry();
particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const particleMat = new THREE.PointsMaterial({
color: 0x88ffee,
size: 0.15,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending
});
holoParticles = new THREE.Points(particleGeo, particleMat);
holoSphere.add(holoParticles);
// Point light from the sphere
const sphereLight = new THREE.PointLight(0x4fd1c5, 0.8, 35);
sphereLight.position.copy(holoSphere.position);
scene.add(sphereLight);
}
function createGlowLights() {
// Floor-standing lamp posts
const lampPositions = [
[20, PLATFORM_HEIGHT, 15],
[-20, PLATFORM_HEIGHT, 15],
[20, PLATFORM_HEIGHT, -20],
[-20, PLATFORM_HEIGHT, -20],
];
lampPositions.forEach(pos => {
// Lamp post
const postGeo = new THREE.CylinderGeometry(0.15, 0.15, 6, 8);
const postMat = new THREE.MeshStandardMaterial({
color: 0x555555,
roughness: 0.3,
metalness: 0.7
});
const post = new THREE.Mesh(postGeo, postMat);
post.position.set(pos[0], pos[1] + 3, pos[2]);
post.castShadow = true;
scene.add(post);
// Lamp bulb
const bulbGeo = new THREE.SphereGeometry(0.4, 16, 16);
const bulbMat = new THREE.MeshBasicMaterial({
color: 0xffcc66,
transparent: true,
opacity: 0.9
});
const bulb = new THREE.Mesh(bulbGeo, bulbMat);
bulb.position.set(pos[0], pos[1] + 6.2, pos[2]);
scene.add(bulb);
// Point light
const light = new THREE.PointLight(0xffcc66, 0.5, 18);
light.position.set(pos[0], pos[1] + 6.2, pos[2]);
light.castShadow = false;
scene.add(light);
glowLights.push({ bulb, light, baseIntensity: 0.5 });
});
}
function createFloatingParticles() {
const particleCount = 80;
const positions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * PLATFORM_SIZE;
positions[i * 3 + 1] = PLATFORM_HEIGHT + 2 + Math.random() * WALL_HEIGHT;
positions[i * 3 + 2] = (Math.random() - 0.5) * PLATFORM_SIZE;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x88ffdd,
size: 0.12,
transparent: true,
opacity: 0.4,
blending: THREE.AdditiveBlending
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
floatingParticles.push(particles);
}
function createBackgroundStars() {
// Distant stars/sparkles in the background
const starCount = 200;
const positions = new Float32Array(starCount * 3);
const sizes = new Float32Array(starCount);
for (let i = 0; i < starCount; i++) {
// Place stars far outside the platform
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const radius = 100 + Math.random() * 150;
positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = 20 + Math.random() * 100;
positions[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta);
sizes[i] = 0.5 + Math.random() * 1.5;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const material = new THREE.PointsMaterial({
color: 0xaaddff,
size: 1.0,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
sizeAttenuation: true
});
const stars = new THREE.Points(geometry, material);
scene.add(stars);
// Animate stars with twinkle effect
stars.userData.twinklePhase = Math.random() * Math.PI * 2;
// Add to floatingParticles for animation
floatingParticles.push(stars);
}
function createAgentDesk(name, position, toolName = null) {
const color = getAgentColor(name);
const group = new THREE.Group();
group.position.copy(position);
group.position.y = PLATFORM_HEIGHT;
// Modern desk - white top with thin legs
const deskTopGeo = new THREE.BoxGeometry(5, 0.2, 3);
const deskMat = new THREE.MeshStandardMaterial({
color: 0xe8e8e8,
roughness: 0.3,
metalness: 0.1
});
const deskTop = new THREE.Mesh(deskTopGeo, deskMat);
deskTop.position.y = 2.5;
deskTop.castShadow = true;
deskTop.receiveShadow = true;
group.add(deskTop);
// Desk legs - thin metal
const legGeo = new THREE.CylinderGeometry(0.08, 0.08, 2.4, 8);
const legMat = new THREE.MeshStandardMaterial({
color: 0x999999,
roughness: 0.2,
metalness: 0.7
});
const legPositions = [
[-2.2, 1.2, -1.2],
[2.2, 1.2, -1.2],
[-2.2, 1.2, 1.2],
[2.2, 1.2, 1.2]
];
legPositions.forEach(pos => {
const leg = new THREE.Mesh(legGeo, legMat);
leg.position.set(...pos);
group.add(leg);
});
// Modern chair - sleek
const chairSeatGeo = new THREE.BoxGeometry(1.8, 0.15, 1.8);
const chairMat = new THREE.MeshStandardMaterial({
color: 0x2a2a2a,
roughness: 0.5,
metalness: 0.2
});
const chairSeat = new THREE.Mesh(chairSeatGeo, chairMat);
chairSeat.position.set(0, 1.6, 3.2);
chairSeat.castShadow = true;
group.add(chairSeat);
// Chair back - curved look (box approximation)
const chairBackGeo = new THREE.BoxGeometry(1.8, 2.2, 0.15);
const chairBack = new THREE.Mesh(chairBackGeo, chairMat);
chairBack.position.set(0, 2.7, 4.1);
chairBack.castShadow = true;
group.add(chairBack);
// Chair post
const chairPostGeo = new THREE.CylinderGeometry(0.1, 0.1, 1.2, 8);
const chairPost = new THREE.Mesh(chairPostGeo, legMat);
chairPost.position.set(0, 0.9, 3.2);
group.add(chairPost);
// Chair base star
for (let i = 0; i < 5; i++) {
const armGeo = new THREE.CylinderGeometry(0.06, 0.06, 1.2, 6);
const arm = new THREE.Mesh(armGeo, legMat);
const angle = (i / 5) * Math.PI * 2;
arm.rotation.z = Math.PI / 2;
arm.position.set(
Math.cos(angle) * 0.5,
0.3,
3.2 + Math.sin(angle) * 0.5
);
arm.rotation.y = angle;
group.add(arm);
}
// Person - Body (sitting, modern look)
const bodyGeo = new THREE.CylinderGeometry(0.6, 0.5, 2, 8);
const bodyMat = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.6,
metalness: 0.05
});
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.set(0, 2.7, 3.2);
body.castShadow = true;
group.add(body);
// Person - Head
const headGeo = new THREE.SphereGeometry(0.5, 16, 16);
const headMat = new THREE.MeshStandardMaterial({
color: 0xf5d0b0,
roughness: 0.7
});
const head = new THREE.Mesh(headGeo, headMat);
head.position.set(0, 4.0, 3.2);
head.castShadow = true;
group.add(head);
// Person - Arms on desk
const armObjGeo = new THREE.CylinderGeometry(0.12, 0.12, 1.8, 6);
const armMat = new THREE.MeshStandardMaterial({ color: color, roughness: 0.6 });
const leftArm = new THREE.Mesh(armObjGeo, armMat);
leftArm.rotation.z = Math.PI / 2;
leftArm.rotation.y = 0.3;
leftArm.position.set(-0.8, 2.8, 2);
group.add(leftArm);
const rightArm = new THREE.Mesh(armObjGeo, armMat);
rightArm.rotation.z = Math.PI / 2;
rightArm.rotation.y = -0.3;
rightArm.position.set(0.8, 2.8, 2);
group.add(rightArm);
// Monitor (modern flat screen)
const monitorStandGeo = new THREE.CylinderGeometry(0.5, 0.6, 0.1, 16);
const monitorMat = new THREE.MeshStandardMaterial({
color: 0x333333,
roughness: 0.3,
metalness: 0.5
});
const monitorStand = new THREE.Mesh(monitorStandGeo, monitorMat);
monitorStand.position.set(0, 2.65, 0.8);
group.add(monitorStand);
const monitorNeckGeo = new THREE.CylinderGeometry(0.08, 0.08, 1.2, 8);
const monitorNeck = new THREE.Mesh(monitorNeckGeo, monitorMat);
monitorNeck.position.set(0, 3.2, 0.8);
group.add(monitorNeck);
// Screen
const screenFrameGeo = new THREE.BoxGeometry(3, 1.8, 0.12);
const screenFrame = new THREE.Mesh(screenFrameGeo, monitorMat);
screenFrame.position.set(0, 4.0, 0.8);
screenFrame.castShadow = true;
group.add(screenFrame);
// Screen display (glowing)
const screenDisplayGeo = new THREE.PlaneGeometry(2.7, 1.5);
const screenDisplayMat = new THREE.MeshBasicMaterial({
color: 0x2a6b5e,
});
const screenDisplay = new THREE.Mesh(screenDisplayGeo, screenDisplayMat);
screenDisplay.position.set(0, 4.0, 0.87);
group.add(screenDisplay);
// Screen glow light
const screenLight = new THREE.PointLight(0x4fd1c5, 0.3, 6);
screenLight.position.set(0, 4.0, 1.5);
group.add(screenLight);
// Keyboard
const kbGeo = new THREE.BoxGeometry(1.6, 0.05, 0.5);
const kbMat = new THREE.MeshStandardMaterial({
color: 0x444444,
roughness: 0.5,
metalness: 0.3
});
const keyboard = new THREE.Mesh(kbGeo, kbMat);
keyboard.position.set(0, 2.63, 2);
group.add(keyboard);
// Name label sprite
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const scale = 2;
canvas.width = 512;
canvas.height = toolName ? 160 : 128;
context.scale(scale, scale);
// Frosted glass background
context.fillStyle = 'rgba(20, 60, 60, 0.85)';
context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
context.fill();
// Subtle border
context.strokeStyle = 'rgba(79, 209, 197, 0.4)';
context.lineWidth = 1;
context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
context.stroke();
context.font = 'bold 22px Arial';
context.fillStyle = '#e0f5f0';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(name, 128, 24);
if (toolName) {
context.font = 'italic 14px Arial';
context.fillStyle = '#4fd1c5';
context.fillText(toolName, 128, 56);
}
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({ map: texture });
const sprite = new THREE.Sprite(spriteMat);
sprite.position.set(0, 6.5, 2);
sprite.scale.set(7, toolName ? 2.2 : 1.8, 1);
sprite.name = 'label';
group.add(sprite);
scene.add(group);
agentMeshes.set(name, group);
return group;
}
function updateDeskLabel(desk, name, toolName = null) {
const sprite = desk.getObjectByName('label');
if (!sprite) return;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const scale = 2;
canvas.width = 512;
canvas.height = toolName ? 160 : 128;
context.scale(scale, scale);
context.fillStyle = 'rgba(20, 60, 60, 0.85)';
context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
context.fill();
context.strokeStyle = 'rgba(79, 209, 197, 0.4)';
context.lineWidth = 1;
context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
context.stroke();
context.font = 'bold 22px Arial';
context.fillStyle = '#e0f5f0';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(name, 128, 24);
if (toolName) {
context.font = 'italic 14px Arial';
context.fillStyle = '#4fd1c5';
context.fillText(toolName, 128, 56);
}
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
sprite.material.map = texture;
sprite.material.needsUpdate = true;
sprite.position.set(0, 6.5, 2);
sprite.scale.set(7, toolName ? 2.2 : 1.8, 1);
}
function createMessageParticle(fromPos, toPos) {
const particleGeo = new THREE.SphereGeometry(0.35, 12, 12);
const particleMat = new THREE.MeshBasicMaterial({
color: 0xff6b6b,
transparent: true,
opacity: 0.95
});
const particle = new THREE.Mesh(particleGeo, particleMat);
particle.position.copy(fromPos);
particle.position.y += 5;
// Add a glow point light that follows particle
const glow = new THREE.PointLight(0xff6b6b, 1.0, 10);
particle.add(glow);
scene.add(particle);
const startTime = Date.now();
const duration = 1500;
function animateParticle() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
particle.position.lerpVectors(
new THREE.Vector3(fromPos.x, fromPos.y + 5, fromPos.z),
new THREE.Vector3(toPos.x, toPos.y + 5, toPos.z),
progress
);
particle.position.y += Math.sin(progress * Math.PI) * 2;
// Pulse size
const pulse = Math.sin(progress * Math.PI) * 0.2 + 1;
particle.scale.setScalar(pulse);
if (progress < 1) {
requestAnimationFrame(animateParticle);
} else {
scene.remove(particle);
}
}
animateParticle();
}
function createConnectionLine(fromPos, toPos) {
const startPos = new THREE.Vector3(fromPos.x, fromPos.y + 5, fromPos.z);
const endPos = new THREE.Vector3(toPos.x, toPos.y + 5, toPos.z);
// Create curved line with points
const mid = new THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5);
mid.y += 2;
const curve = new THREE.QuadraticBezierCurve3(startPos, mid, endPos);
const points = curve.getPoints(50);
// Main line - thicker, glowing red
const material = new THREE.LineBasicMaterial({
color: 0xff6b6b,
opacity: 0.7,
transparent: true,
linewidth: 3
});
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, material);
scene.add(line);
connectionLines.push(line);
// Add small chevron markers along the path to show direction
const direction = new THREE.Vector3().subVectors(endPos, startPos).normalize();
const up = new THREE.Vector3(0, 1, 0);
const numMarkers = 4;
for (let i = 0; i < numMarkers; i++) {
const t = (i + 1) / (numMarkers + 1);
const markerPos = curve.getPoint(t);
const markerGeo = new THREE.ConeGeometry(0.25, 0.7, 8);
const markerMat = new THREE.MeshBasicMaterial({
color: 0xff6b6b,
transparent: true,
opacity: 0.5
});
const marker = new THREE.Mesh(markerGeo, markerMat);
// Get tangent at this point for direction
const tangent = curve.getTangent(t);
marker.position.copy(markerPos);
const markerQuat = new THREE.Quaternion();
markerQuat.setFromUnitVectors(up, tangent);
marker.setRotationFromQuaternion(markerQuat);
scene.add(marker);
connectionLines.push(marker);
}
setTimeout(() => {
createMessageParticle(fromPos, toPos);
}, 50);
}
function clearConnections() {
connectionLines.forEach(line => scene.remove(line));
connectionLines = [];
}
function updateVillage() {
clearConnections();
// Use recipients (from coworkers.db) as the authoritative list of agents
// Only show people in the coworker list, not random message senders
const allAgents = new Set([config.user.toLowerCase(), ...recipients.map(r => r.toLowerCase())]);
// Arrange agents in a circle on the platform
const agents = Array.from(allAgents);
const radius = Math.min(20, Math.max(10, agents.length * 3));
agents.forEach((agent, index) => {
const angle = (index / agents.length) * Math.PI * 2 - Math.PI / 2;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
const position = new THREE.Vector3(x, 0, z);
const statusState = statusStates[agent.toLowerCase()];
const toolName = statusState?.tool_name || null;
if (!agentMeshes.has(agent)) {
const group = createAgentDesk(agent, position, toolName);
// Face desk toward center
group.lookAt(new THREE.Vector3(0, PLATFORM_HEIGHT, 0));
} else {
const desk = agentMeshes.get(agent);
desk.position.set(x, PLATFORM_HEIGHT, z);
desk.lookAt(new THREE.Vector3(0, PLATFORM_HEIGHT, 0));
updateDeskLabel(desk, agent, toolName);
}
});
// Remove desks for coworkers that no longer exist
agentMeshes.forEach((desk, name) => {
if (!allAgents.has(name)) {
// Remove from scene
scene.remove(desk);
// Dispose of geometries and materials to prevent memory leaks
desk.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose());
} else {
child.material.dispose();
}
}
}
});
// Remove from Map
agentMeshes.delete(name);
}
});
// Create connections for unread messages only
allMessages.forEach(msg => {
const fromDesk = agentMeshes.get(msg.sender.toLowerCase());
const toDesk = agentMeshes.get(msg.recipient.toLowerCase());
if (fromDesk && toDesk && !msg.read) {
createConnectionLine(
fromDesk.position,
toDesk.position
);
}
});
// Update desk labels with unread indicators
updateDeskLabels();
}
function animate() {
requestAnimationFrame(animate);
const time = Date.now() * 0.001;
// Rotate holographic sphere
if (holoSphere) {
holoSphere.rotation.y = time * 0.15;
holoSphere.rotation.x = Math.sin(time * 0.1) * 0.1;
}
// Animate floating particles
floatingParticles.forEach((particles, index) => {
const positions = particles.geometry.attributes.position.array;
if (particles.userData.twinklePhase !== undefined) {
// Star twinkling effect
const twinkle = Math.sin(time * 2 + particles.userData.twinklePhase) * 0.3 + 0.7;
particles.material.opacity = 0.4 + twinkle * 0.4;
// Slowly rotate stars
particles.rotation.y = time * 0.02;
} else {
// Regular floating particles
for (let i = 0; i < positions.length; i += 3) {
positions[i + 1] += Math.sin(time + positions[i] * 0.1) * 0.003;
}
particles.geometry.attributes.position.needsUpdate = true;
}
});
// Subtle glow pulse on lamps
glowLights.forEach((item, i) => {
const pulse = Math.sin(time * 1.5 + i) * 0.15 + 1;
item.light.intensity = item.baseIntensity * pulse;
});
// Animate water ripples
if (waterMesh && waterMesh.userData.initialPositions) {
const positions = waterMesh.geometry.attributes.position;
const initialPositions = waterMesh.userData.initialPositions;
for (let i = 0; i < positions.count; i++) {
const x = initialPositions[i * 3];
const y = initialPositions[i * 3 + 1];
// Create gentle ripple effect
const distance = Math.sqrt(x * x + y * y);
const wave1 = Math.sin(distance * 0.3 - time * 0.8) * 0.15;
const wave2 = Math.sin(x * 0.2 + time * 0.5) * 0.1;
const wave3 = Math.cos(y * 0.15 + time * 0.3) * 0.08;
positions.setZ(i, wave1 + wave2 + wave3);
}
positions.needsUpdate = true;
}
controls.update();
// Use composer for bloom effect if available, otherwise standard renderer
if (composer) {
composer.render();
} else {
renderer.render(scene, camera);
}
}
function onWindowResize() {
const width = window.innerWidth;
const height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
// Resize composer for bloom effect
if (composer) {
composer.setSize(width, height);
}
if (width < 768) {
camera.position.y = Math.max(camera.position.y, 40);
camera.position.z = Math.max(camera.position.z, 50);
controls.minDistance = 35;
} else {
controls.minDistance = 25;
}
}
// API and UI Functions
async function loadData() {
try {
const [configRes, messagesRes, coworkersRes, allMessagesRes] = await Promise.all([
fetch('/api/config'),
fetch('/api/messages'),
fetch('/api/coworkers'),
fetch('/api/messages/all')
]);
config = await configRes.json();
const messagesData = await messagesRes.json();
const recipientsData = await coworkersRes.json();
const allMessagesData = await allMessagesRes.json();
// Validate responses are arrays (not error objects)
messages = Array.isArray(messagesData) ? messagesData : [];
recipients = Array.isArray(recipientsData) ? recipientsData : [];
allMessages = Array.isArray(allMessagesData) ? allMessagesData : [];
// Load status states if status DB is configured
if (config.status) {
try {
const statusRes = await fetch('/api/status');
statusStates = await statusRes.json();
} catch (err) {
console.error('Error loading status states:', err);
statusStates = {};
}
}
updateUI();
updateVillage();
} catch (err) {
console.error('Error loading data:', err);
}
}
// Panel toggle functions
window.toggleSendPanel = function() {
const panel = document.getElementById('send-panel');
const btn = document.getElementById('collapse-btn');
panel.classList.toggle('collapsed');
btn.textContent = panel.classList.contains('collapsed') ? '+' : '−';
};
window.toggleMessagesPanel = function() {
const panel = document.getElementById('messages-panel');
const btn = document.getElementById('toggle-messages-btn');
panel.classList.toggle('open');
btn.style.opacity = panel.classList.contains('open') ? '0' : '1';
btn.style.pointerEvents = panel.classList.contains('open') ? 'none' : 'auto';
};
function updateUI() {
const unread = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase()).length;
// Update messages button - change icon and show badge when unread
const msgBtn = document.getElementById('toggle-messages-btn');
const badge = document.getElementById('unread-badge');
if (unread > 0) {
msgBtn.innerHTML = `🔔 Messages <span class="badge" id="unread-badge">${unread}</span>`;
} else {
msgBtn.innerHTML = `📨 Messages <span class="badge" id="unread-badge" style="display: none;">0</span>`;
}
// Update recipient select (send panel) - only from coworkers.db
const select = document.getElementById('recipient-select');
const currentVal = select.value;
const everyoneOption = recipients.length > 0 ? '<option value="@everyone" style="font-weight: bold; color: #5EEAD4;">@everyone (broadcast to all)</option>' : '';
select.innerHTML = '<option value="">Coworker...</option>' +
everyoneOption +
recipients.sort().map(r =>
`<option value="${r}" ${r === currentVal ? 'selected' : ''}>${r}</option>`
).join('');
// Update messages list (slide-out panel)
const messagesDiv = document.getElementById('messages-container');
if (messages.length === 0) {
messagesDiv.innerHTML = `
<div class="empty-state">
<div style="font-size: 2rem; margin-bottom: 8px;">📭</div>
<p>No messages yet</p>
</div>
`;
} else {
messagesDiv.innerHTML = messages.slice(0, 20).map(msg => renderMessageCard(msg, true)).join('');
// Add click handlers for all messages (clicking marks as read and sets recipient for reply)
messagesDiv.querySelectorAll('.message-card').forEach(el => {
el.addEventListener('click', () => {
const msgId = el.dataset.id;
const sender = el.dataset.sender;
const recipient = el.dataset.recipient;
// Determine who to reply to
// If I received the message, reply to sender
// If I sent the message, reply to the original recipient
const myName = config.user.toLowerCase();
const replyTo = recipient.toLowerCase() === myName ? sender : recipient;
// Set the recipient select
const select = document.getElementById('recipient-select');
if (select) {
select.value = replyTo;
}
// Mark as read
markAsRead(msgId);
// Expand send panel if collapsed
const sendPanel = document.getElementById('send-panel');
if (sendPanel && sendPanel.classList.contains('collapsed')) {
toggleSendPanel();
}
// Focus the message input for typing
const messageInput = document.getElementById('message-input');
if (messageInput) {
messageInput.focus();
}
});
});
}
// Update desk dialog if it's open
if (document.getElementById('house-dialog').classList.contains('active')) {
updateDeskDialogContent();
}
}
async function markAsRead(id) {
try {
await fetch(`/api/messages/${id}/read`, { method: 'POST' });
loadData();
} catch (err) {
console.error('Error marking as read:', err);
}
}
window.markAllAsRead = async function() {
const unreadMessages = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase());
if (unreadMessages.length === 0) return;
try {
await Promise.all(unreadMessages.map(m => fetch(`/api/messages/${m.id}/read`, { method: 'POST' })));
loadData();
} catch (err) {
console.error('Error marking all as read:', err);
}
};
async function sendMessage() {
const to = document.getElementById('recipient-select').value;
const message = document.getElementById('message-input').value.trim();
if (!to || !message) {
alert('Please select a coworker and enter a message');
return;
}
try {
let sendPromises;
if (to === '@everyone') {
// Send to all recipients individually
sendPromises = recipients.map(recipient =>
fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: recipient, from: config.user, message })
})
);
} else {
// Send to single recipient
sendPromises = [fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to, from: config.user, message })
})];
}
const responses = await Promise.all(sendPromises);
const allSuccessful = responses.every(r => r.ok);
if (allSuccessful) {
// Clear input
document.getElementById('message-input').value = '';
// Show toast
const toast = document.getElementById('toast');
const toastMsg = document.getElementById('toast-message');
if (toastMsg && to === '@everyone') {
toastMsg.textContent = `Message broadcast to ${recipients.length} coworkers!`;
}
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
if (toastMsg) toastMsg.textContent = 'Message sent!';
}, 3000);
// Reload data
loadData();
} else {
alert('Failed to send message to some recipients');
}
} catch (err) {
console.error('Error sending:', err);
alert('Error sending message');
}
}
// Desk click handler
function onDeskClick(event) {
handleDeskInteraction(event.clientX, event.clientY);
}
// Touch handlers for mobile
let touchStartX = 0;
let touchStartY = 0;
function onDeskTouchStart(event) {
if (event.touches.length === 1) {
touchStartX = event.touches[0].clientX;
touchStartY = event.touches[0].clientY;
}
}
function onDeskTouchEnd(event) {
if (event.changedTouches.length === 1) {
const touchEndX = event.changedTouches[0].clientX;
const touchEndY = event.changedTouches[0].clientY;
// Check if touch moved significantly (if so, it's a drag/pan, not a tap)
const moveDistance = Math.sqrt(
Math.pow(touchEndX - touchStartX, 2) +
Math.pow(touchEndY - touchStartY, 2)
);
// Only trigger if touch didn't move much (tap vs swipe)
if (moveDistance < 20) {
handleDeskInteraction(touchEndX, touchEndY);
}
}
}
// Common desk interaction handler
function handleDeskInteraction(clientX, clientY) {
// Calculate normalized device coordinates
const rect = renderer.domElement.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
const y = -((clientY - rect.top) / rect.height) * 2 + 1;
mouse.x = x;
mouse.y = y;
raycaster.setFromCamera(mouse, camera);
// Get all desk meshes
const deskMeshes = [];
agentMeshes.forEach((group, name) => {
group.children.forEach(child => {
if (child.isMesh && !child.userData.isBubble && !child.userData.isCup) {
child.userData.agentName = name;
deskMeshes.push(child);
}
});
});
const intersects = raycaster.intersectObjects(deskMeshes);
if (intersects.length > 0) {
const agentName = intersects[0].object.userData.agentName;
if (agentName) {
showDeskDialog(agentName);
}
}
}
// Global variable to track current agent for desk dialog
let currentDeskAgent = null;
let currentTab = 'received';
// Show dialog with messages for a specific agent
async function showDeskDialog(agentName) {
currentDeskAgent = agentName.toLowerCase();
currentTab = 'received'; // Default to received tab
const dialog = document.getElementById('house-dialog');
const title = document.getElementById('house-dialog-title');
// Capitalize first letter
const displayName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
title.textContent = `${displayName}'s Messages`;
// Update tab labels
document.getElementById('tab-received').innerHTML =
`📥 Received by ${displayName} <span id="received-count" class="tab-badge"></span>`;
document.getElementById('tab-sent').innerHTML =
`📤 Sent by ${displayName} <span id="sent-count" class="tab-badge"></span>`;
// Load all messages (both sent and received) for this dialog
try {
const response = await fetch('/api/messages/all');
allMessages = await response.json();
} catch (err) {
console.error('Error loading all messages:', err);
allMessages = [];
}
// Switch to received tab by default
switchTab('received');
dialog.classList.add('active');
}
// Tab switching function
window.switchTab = function(tab) {
currentTab = tab;
// Update tab buttons
document.getElementById('tab-received').classList.toggle('active', tab === 'received');
document.getElementById('tab-sent').classList.toggle('active', tab === 'sent');
// Update content
updateDeskDialogContent();
};
function updateDeskDialogContent() {
const content = document.getElementById('house-dialog-content');
if (!currentDeskAgent) return;
// Filter messages based on current tab - FROM THE AGENT'S PERSPECTIVE
let filteredMessages;
if (currentTab === 'received') {
// Messages RECEIVED BY the agent (sent TO the agent)
filteredMessages = allMessages.filter(m =>
m.recipient.toLowerCase() === currentDeskAgent
);
} else {
// Messages SENT BY the agent
filteredMessages = allMessages.filter(m =>
m.sender.toLowerCase() === currentDeskAgent
);
}
// Update count badges
const receivedCount = allMessages.filter(m =>
m.recipient.toLowerCase() === currentDeskAgent
).length;
const sentCount = allMessages.filter(m =>