← back to Dw War Room

src/scene/DataWalls.ts

388 lines

// ═══════════════════════════════════════════════
// DW War Room 3D — Data Wall Panels
// Floating panels showing live metrics
// Canvas-textured with scanline sweep + corner brackets
// ═══════════════════════════════════════════════
//
// Usage:
//   const walls = new DataWalls(scene);
//   walls.animate(elapsedMs);
//   walls.pulsePanel('escalations', 0.3);
//   walls.updatePanelData('decisions', { Pending: '7', Approved: '42', Rejected: '3' });

import * as THREE from 'three';

// ─── Canvas texture dimensions ───────────────────────────────────────────────
const CANVAS_W = 768;
const CANVAS_H = 512;

// ─── Scanline sweep period (ms) ──────────────────────────────────────────────
const SWEEP_PERIOD_MS = 4000;

// ─── Default metric values per panel ────────────────────────────────────────
const DEFAULT_DATA: Record<string, Record<string, string>> = {
  decisions:   { Pending: '—', Approved: '—', Rejected: '—', Deferred: '—' },
  escalations: { Active: '—', 'Avg Age': '—h', Critical: '—', Resolved: '—' },
  meeting:     { Status: 'IDLE', Phase: '—', Agents: '—', Duration: '—' },
  status:      { 'CPU': '—%', 'Memory': '—%', 'Services': '—', 'Uptime': '—' },
};

// ─── Panel definition ────────────────────────────────────────────────────────
interface PanelDef {
  name: string;
  x: number;
  y: number;
  z: number;
  width: number;
  height: number;
  accentColor: number;
  accentHex: string;     // CSS hex for canvas rendering
  labelCaps: string;     // Header text shown on the panel
}

const PANEL_DEFS: PanelDef[] = [
  { name: 'decisions',   x: -12, y: 6, z: 0, width: 5, height: 4, accentColor: 0x8b5cf6, accentHex: '#8b5cf6', labelCaps: 'DECISIONS'   },
  { name: 'escalations', x:  12, y: 6, z: 0, width: 5, height: 4, accentColor: 0xef4444, accentHex: '#ef4444', labelCaps: 'ESCALATIONS' },
  { name: 'meeting',     x: -10, y: 2, z: 8, width: 6, height: 3, accentColor: 0x3b82f6, accentHex: '#3b82f6', labelCaps: 'MEETING'     },
  { name: 'status',      x:  10, y: 2, z: 8, width: 6, height: 3, accentColor: 0x22c55e, accentHex: '#22c55e', labelCaps: 'SYS STATUS'  },
];

// ─── Per-panel runtime state ─────────────────────────────────────────────────
interface PanelState {
  def: PanelDef;
  mesh: THREE.Mesh;
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;
  texture: THREE.CanvasTexture;
  sweepY: number;           // current scanline y position in canvas pixels
  data: Record<string, string>;
  baseY: number;            // world-space Y at creation (for hover animation)
}

// ─── Corner bracket geometry helpers ────────────────────────────────────────
// Returns 8 points (4 corners × 2 lines each) for L-shaped corner brackets.
// All coordinates are in local panel space; the caller positions the LineSegments.
function buildCornerBracketPoints(w: number, h: number, arm: number): Float32Array {
  const hw = w / 2;
  const hh = h / 2;
  // Each corner: 2 line segments = 4 points.
  // Order: (x1,y1,z), (x2,y2,z) per segment.
  return new Float32Array([
    // Top-left corner — horizontal arm
    -hw, hh, 0,  -hw + arm, hh, 0,
    // Top-left corner — vertical arm
    -hw, hh, 0,  -hw, hh - arm, 0,

    // Top-right corner — horizontal arm
     hw, hh, 0,   hw - arm, hh, 0,
    // Top-right corner — vertical arm
     hw, hh, 0,   hw, hh - arm, 0,

    // Bottom-left corner — horizontal arm
    -hw, -hh, 0,  -hw + arm, -hh, 0,
    // Bottom-left corner — vertical arm
    -hw, -hh, 0,  -hw, -hh + arm, 0,

    // Bottom-right corner — horizontal arm
     hw, -hh, 0,   hw - arm, -hh, 0,
    // Bottom-right corner — vertical arm
     hw, -hh, 0,   hw, -hh + arm, 0,
  ]);
}

// ─── Canvas draw helpers ────────────────────────────────────────────────────

function drawPanelCanvas(
  ctx: CanvasRenderingContext2D,
  def: PanelDef,
  data: Record<string, string>,
  sweepY: number
): void {
  const { accentHex, labelCaps } = def;
  const W = CANVAS_W;
  const H = CANVAS_H;

  // Background
  ctx.fillStyle = '#0a0a1e';
  ctx.fillRect(0, 0, W, H);

  // Header bar (top strip, 52px tall)
  const headerH = 52;
  ctx.fillStyle = accentHex;
  ctx.globalAlpha = 0.18;
  ctx.fillRect(0, 0, W, headerH);
  ctx.globalAlpha = 1.0;

  // Header text
  ctx.font = 'bold 22px Inter, monospace';
  ctx.fillStyle = accentHex;
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'left';
  ctx.fillText(labelCaps, 18, headerH / 2);

  // Live indicator dot
  const dotX = W - 14;
  const dotY = headerH / 2;
  ctx.beginPath();
  ctx.arc(dotX, dotY, 4, 0, Math.PI * 2);
  ctx.fillStyle = accentHex;
  ctx.globalAlpha = 0.9;
  ctx.fill();
  ctx.globalAlpha = 1.0;

  // Divider line below header
  ctx.strokeStyle = accentHex;
  ctx.globalAlpha = 0.35;
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, headerH);
  ctx.lineTo(W, headerH);
  ctx.stroke();
  ctx.globalAlpha = 1.0;

  // Metric rows
  const entries = Object.entries(data);
  const rowH = Math.floor((H - headerH - 24) / Math.max(entries.length, 1));
  const colLabelX = 18;
  const colValueX = W - 18;
  const startY = headerH + 14;

  entries.forEach(([key, value], i) => {
    const rowY = startY + i * rowH + rowH / 2;

    // Key label — larger, brighter for legibility
    ctx.font = '16px Inter, monospace';
    ctx.fillStyle = '#b0b4c8';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.fillText(key.toUpperCase(), colLabelX, rowY);

    // Value — accent-colored, bold, much larger
    ctx.font = 'bold 28px Inter, monospace';
    ctx.fillStyle = value === '—' ? '#3a3a5a' : accentHex;
    ctx.textAlign = 'right';
    ctx.fillText(value, colValueX, rowY);

    // Subtle row divider
    if (i < entries.length - 1) {
      ctx.strokeStyle = '#1e1e3a';
      ctx.globalAlpha = 0.8;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(colLabelX, rowY + rowH / 2);
      ctx.lineTo(W - colLabelX, rowY + rowH / 2);
      ctx.stroke();
      ctx.globalAlpha = 1.0;
    }

    // Progress bar beneath value (proportional fill based on numeric parse)
    const numVal = parseFloat(value);
    if (!isNaN(numVal) && numVal > 0) {
      const barMaxW = 140;
      const barH = 6;
      const barY = rowY + 16;
      const fill = Math.min(numVal / 100, 1.0);
      ctx.fillStyle = '#1a1a38';
      ctx.beginPath();
      ctx.roundRect(colValueX - barMaxW, barY, barMaxW, barH, 3);
      ctx.fill();
      ctx.fillStyle = accentHex;
      ctx.globalAlpha = 0.8;
      ctx.beginPath();
      ctx.roundRect(colValueX - barMaxW, barY, barMaxW * fill, barH, 3);
      ctx.fill();
      ctx.globalAlpha = 1.0;
    }
  });

  // Scanline sweep — bright horizontal band
  if (sweepY >= 0 && sweepY <= H) {
    const grad = ctx.createLinearGradient(0, sweepY - 6, 0, sweepY + 6);
    grad.addColorStop(0, 'rgba(255,255,255,0)');
    grad.addColorStop(0.4, `${accentHex}55`);
    grad.addColorStop(0.5, `${accentHex}bb`);
    grad.addColorStop(0.6, `${accentHex}55`);
    grad.addColorStop(1, 'rgba(255,255,255,0)');
    ctx.fillStyle = grad;
    ctx.fillRect(0, sweepY - 6, W, 12);
  }

  // Subtle vignette border
  const vignette = ctx.createRadialGradient(W / 2, H / 2, H * 0.25, W / 2, H / 2, H * 0.85);
  vignette.addColorStop(0, 'rgba(0,0,0,0)');
  vignette.addColorStop(1, 'rgba(0,0,0,0.45)');
  ctx.fillStyle = vignette;
  ctx.fillRect(0, 0, W, H);
}

// ─── DataWalls class ─────────────────────────────────────────────────────────

export class DataWalls {
  // Expose panels map for external pulsePanel usage (keyed by panel name)
  private panels: Map<string, THREE.Mesh> = new Map();
  private states: Map<string, PanelState> = new Map();
  private group: THREE.Group;

  constructor(private scene: THREE.Scene) {
    this.group = new THREE.Group();
    this.group.name = 'data-walls';
    scene.add(this.group);
    this.createPanels();
  }

  // ── Panel construction ──────────────────────────────────────────────────────
  private createPanels(): void {
    for (const def of PANEL_DEFS) {
      this.createPanel(def);
    }
  }

  private createPanel(def: PanelDef): void {
    const { name, x, y, z, width, height, accentColor } = def;

    // ── Canvas + texture ──
    const canvas = document.createElement('canvas');
    canvas.width = CANVAS_W;
    canvas.height = CANVAS_H;
    const ctx = canvas.getContext('2d')!;

    const initialData = { ...DEFAULT_DATA[name] } || {};
    drawPanelCanvas(ctx, def, initialData, -20);   // -20 = scanline off-screen initially

    const texture = new THREE.CanvasTexture(canvas);
    texture.minFilter = THREE.LinearFilter;
    texture.magFilter = THREE.LinearFilter;

    // ── Panel mesh (MeshBasicMaterial for sharp canvas text — no lighting wash) ──
    const geo = new THREE.PlaneGeometry(width, height);
    const mat = new THREE.MeshBasicMaterial({
      map: texture,
      transparent: true,
      opacity: 0.92,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(geo, mat);
    mesh.position.set(x, y, z);
    mesh.name = `panel-${name}`;

    // Face toward scene center slightly
    if (x !== 0) {
      mesh.rotation.y = x > 0 ? -Math.PI * 0.15 : Math.PI * 0.15;
    }

    this.group.add(mesh);
    this.panels.set(name, mesh);

    // ── Header bar strip — thin BoxGeometry at top of panel ──
    const barThickness = 0.04;
    const barGeo = new THREE.BoxGeometry(width, 0.22, barThickness);
    const barMat = new THREE.MeshStandardMaterial({
      color: accentColor,
      emissive: new THREE.Color(accentColor),
      emissiveIntensity: 0.5,
      roughness: 0.4,
      metalness: 0.2,
    });
    const bar = new THREE.Mesh(barGeo, barMat);
    bar.name = `bar-${name}`;
    // Position at top edge of panel, slightly in front of it
    bar.position.set(x, y + height / 2 - 0.11, z + 0.02);
    bar.rotation.copy(mesh.rotation);
    this.group.add(bar);

    // ── Corner brackets (Line segments — no full frame) ──
    const armLength = Math.min(width, height) * 0.18;
    const bracketPositions = buildCornerBracketPoints(width, height, armLength);
    const bracketGeo = new THREE.BufferGeometry();
    bracketGeo.setAttribute('position', new THREE.BufferAttribute(bracketPositions, 3));
    const bracketMat = new THREE.LineBasicMaterial({
      color: accentColor,
      transparent: true,
      opacity: 0.75,
    });
    const brackets = new THREE.LineSegments(bracketGeo, bracketMat);
    brackets.position.copy(mesh.position);
    brackets.rotation.copy(mesh.rotation);
    // Offset slightly in front so brackets are visible over panel face
    brackets.position.z += 0.01;
    if (x !== 0 && z === 0) {
      // Recalculate the Z-forward offset in world space after rotation
      // Simpler: just nudge along local Z via translateZ after rotation is applied
      brackets.translateZ(0.02);
    }
    this.group.add(brackets);

    // ── Store runtime state ──
    const state: PanelState = {
      def,
      mesh,
      canvas,
      ctx,
      texture,
      sweepY: 0,
      data: initialData,
      baseY: y,
    };
    this.states.set(name, state);
  }

  // ── Public: update panel data and redraw ────────────────────────────────────
  updatePanelData(name: string, data: Record<string, string>): void {
    const state = this.states.get(name);
    if (!state) return;

    // Merge incoming data over existing data
    state.data = { ...state.data, ...data };
    this.redrawPanel(state);
  }

  // ── Public: pulse emissive (brightness flash from external events) ──────────
  pulsePanel(name: string, intensity: number): void {
    // With MeshBasicMaterial there's no emissive, so we modulate opacity instead
    const panel = this.panels.get(name);
    if (!panel) return;
    const mat = panel.material as THREE.MeshBasicMaterial;
    // Map intensity to opacity range [0.70, 1.0]
    mat.opacity = 0.70 + Math.min(intensity, 1.0) * 0.30;
    mat.needsUpdate = true;

    // Also briefly brighten the header bar — find it by name convention
    const bar = this.group.children.find(
      c => c instanceof THREE.Mesh && c.name === `bar-${name}`
    ) as THREE.Mesh | undefined;
    if (bar) {
      const bMat = bar.material as THREE.MeshStandardMaterial;
      bMat.emissiveIntensity = 0.5 + intensity * 0.8;
    }
  }

  // ── Public: main animate loop ───────────────────────────────────────────────
  animate(time: number): void {
    this.states.forEach((state) => {
      // Subtle hover — different phase per panel so they move independently
      const phase = this.hashName(state.def.name);
      state.mesh.position.y = state.baseY + Math.sin(time * 0.0008 + phase) * 0.03;

      // Advance scanline sweep
      const progress = (time % SWEEP_PERIOD_MS) / SWEEP_PERIOD_MS;
      state.sweepY = Math.floor(progress * CANVAS_H);

      // Redraw canvas with updated sweep position
      this.redrawPanel(state);
    });
  }

  // ── Internal redraw ──────────────────────────────────────────────────────────
  private redrawPanel(state: PanelState): void {
    drawPanelCanvas(state.ctx, state.def, state.data, state.sweepY);
    state.texture.needsUpdate = true;
  }

  // ── Utility ──────────────────────────────────────────────────────────────────
  private hashName(name: string): number {
    let h = 0;
    for (let i = 0; i < name.length; i++) h += name.charCodeAt(i);
    return h;
  }
}