← 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;
      }
    });
  }
}