← back to Dw War Room
src/scene/AgentNodes.ts
257 lines
// ═══════════════════════════════════════════════
// DW War Room 3D — Agent Node Visualization
// 47 agent spheres in org hierarchy + 5 exec nodes
// Text label sprites + raycasting support
// ═══════════════════════════════════════════════
import * as THREE from 'three';
import type { AgentNode } from '../types';
// Exec colors matching production config
const EXEC_COLORS: Record<string, number> = {
ceo: 0x5B21B6,
cfo: 0x065F46,
coo: 0xCD7F32,
cto: 0x9B111E,
'vp-ops': 0x6D28D9,
};
// Department → exec mapping
const DEPT_EXEC: Record<string, string> = {
'Operations': 'coo',
'Commerce': 'cfo',
'Finance': 'cfo',
'Legal': 'ceo',
'Marketing': 'vp-ops',
'Catalogs': 'vp-ops',
'Catalogs-Heritage': 'vp-ops',
'Catalogs-Modern': 'vp-ops',
'Catalogs-National': 'vp-ops',
'Pipeline': 'vp-ops',
'Data': 'cto',
'Imagery': 'vp-ops',
};
// ─── Text sprite helper ────────────────────────────────────────────────────
function createTextSprite(text: string, colorHex: string, fontSize: number = 28): THREE.Sprite {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d')!;
// Transparent background
ctx.clearRect(0, 0, 256, 64);
// Text with glow
ctx.font = `bold ${fontSize}px Inter, -apple-system, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Shadow/glow
ctx.shadowColor = '#000';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 1;
ctx.fillStyle = colorHex;
ctx.fillText(text, 128, 32);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
});
const sprite = new THREE.Sprite(mat);
sprite.scale.set(2, 0.5, 1);
return sprite;
}
export class AgentNodeManager {
private nodes: Map<string, { mesh: THREE.Mesh; glow: THREE.Mesh; label: THREE.Sprite; data: AgentNode }> = new Map();
private group: THREE.Group;
private connections: THREE.Line[] = [];
constructor(private scene: THREE.Scene) {
this.group = new THREE.Group();
this.group.name = 'agent-nodes';
scene.add(this.group);
}
buildHierarchy(agents: any[], executives: any[]): void {
// Clear existing
this.group.clear();
this.nodes.clear();
this.connections.forEach(c => this.scene.remove(c));
this.connections = [];
// Place executives in a ring at top
const execRadius = 3;
executives.forEach((exec: any, i: number) => {
const angle = (i / executives.length) * Math.PI * 2 - Math.PI / 2;
const x = Math.cos(angle) * execRadius;
const z = Math.sin(angle) * execRadius;
this.createNode(exec.id, exec.name, exec.role, 'Executive', exec.color, x, 5, z, 0.4);
});
// Place agents in rings below their exec
const execAgents: Record<string, any[]> = {};
agents.forEach((agent: any) => {
const exec = DEPT_EXEC[agent.dept] || 'vp-ops';
if (!execAgents[exec]) execAgents[exec] = [];
execAgents[exec].push(agent);
});
Object.entries(execAgents).forEach(([execId, agentList]) => {
const execNode = this.nodes.get(execId);
if (!execNode) return;
const baseX = execNode.data.position.x;
const baseZ = execNode.data.position.z;
const ringRadius = Math.min(2 + agentList.length * 0.15, 5);
agentList.forEach((agent: any, i: number) => {
const angle = (i / agentList.length) * Math.PI * 2;
const x = baseX + Math.cos(angle) * ringRadius;
const z = baseZ + Math.sin(angle) * ringRadius;
const y = 2;
this.createNode(agent.id, agent.name, agent.role, agent.dept, agent.color, x, y, z, 0.2);
// Connection line to exec
this.drawConnection(execNode.mesh.position, new THREE.Vector3(x, y, z), execNode.data.color);
});
});
}
private createNode(
id: string, name: string, role: string, dept: string,
colorHex: string, x: number, y: number, z: number, size: number
): void {
const color = new THREE.Color(colorHex);
// Main sphere — MeshStandardMaterial for PBR lighting
const geo = new THREE.SphereGeometry(size, 32, 32);
const mat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.25,
metalness: 0.6,
roughness: 0.3,
transparent: true,
opacity: 0.92,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.name = `node-${id}`;
// Store agent ID in userData for raycasting lookup
mesh.userData.agentId = id;
this.group.add(mesh);
// Glow shell (invisible by default — lights up when speaking)
const glowGeo = new THREE.SphereGeometry(size * 1.6, 16, 16);
const glowMat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.0,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
glow.position.set(x, y, z);
glow.name = `glow-${id}`;
this.group.add(glow);
// Text label above node
const isExec = dept === 'Executive';
const displayName = isExec ? name.toUpperCase() : name.split(' ').pop() || name;
const label = createTextSprite(displayName, isExec ? '#e8eaf0' : '#9ca3af', isExec ? 30 : 22);
label.position.set(x, y + size + 0.5, z);
label.scale.set(isExec ? 2.4 : 1.6, isExec ? 0.6 : 0.4, 1);
this.group.add(label);
const data: AgentNode = {
id, name, role, dept, color: colorHex,
exec: DEPT_EXEC[dept] || 'vp-ops',
speaking: false,
position: { x, y, z },
};
this.nodes.set(id, { mesh, glow, label, data });
}
private drawConnection(from: THREE.Vector3, to: THREE.Vector3, colorHex: string): void {
const points = [from.clone(), to.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({
color: new THREE.Color(colorHex),
transparent: true,
opacity: 0.15,
});
const line = new THREE.Line(geo, mat);
this.scene.add(line);
this.connections.push(line);
}
setSpeaking(agentId: string, speaking: boolean): void {
const node = this.nodes.get(agentId);
if (!node) return;
node.data.speaking = speaking;
const glowMat = node.glow.material as THREE.MeshBasicMaterial;
glowMat.opacity = speaking ? 0.3 : 0.0;
const meshMat = node.mesh.material as THREE.MeshStandardMaterial;
meshMat.emissiveIntensity = speaking ? 0.9 : 0.25;
}
clearAllSpeaking(): void {
this.nodes.forEach((node) => {
this.setSpeaking(node.data.id, false);
});
}
// Get all agent sphere meshes for raycasting
getMeshes(): THREE.Mesh[] {
const meshes: THREE.Mesh[] = [];
this.nodes.forEach(node => meshes.push(node.mesh));
return meshes;
}
// Lookup agent data from a raycasted mesh
getNodeByMesh(mesh: THREE.Object3D): AgentNode | null {
const agentId = mesh.userData?.agentId;
if (!agentId) return null;
const node = this.nodes.get(agentId);
return node ? node.data : null;
}
// Get agent data by ID
getAgent(id: string): AgentNode | null {
const node = this.nodes.get(id);
return node ? node.data : null;
}
// Get all agent data
getAllAgents(): AgentNode[] {
const agents: AgentNode[] = [];
this.nodes.forEach(n => agents.push(n.data));
return agents;
}
animate(time: number): void {
// Subtle floating animation for all nodes
this.nodes.forEach((node) => {
const offset = parseInt(node.data.id, 36) * 0.5;
node.mesh.position.y = node.data.position.y + Math.sin(time * 0.001 + offset) * 0.05;
node.glow.position.y = node.mesh.position.y;
// Keep label above the sphere
node.label.position.y = node.mesh.position.y + 0.5;
// Pulse glow for speaking agents
if (node.data.speaking) {
const glowMat = node.glow.material as THREE.MeshBasicMaterial;
glowMat.opacity = 0.15 + Math.sin(time * 0.005) * 0.15;
}
});
}
}