Back to The Times of Claw

Build a 3D Data Visualization with Three.js and DenchClaw

Build a 3D data visualization app in DenchClaw using Three.js to render your CRM pipeline, deal network graph, or contact relationships in three dimensions.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a 3D Data Visualization with Three.js and DenchClaw

Build a 3D Data Visualization with Three.js and DenchClaw

Standard charts are fine. But sometimes your data has structure that 2D charts can't show: deal networks, contact relationship graphs, geographic pipeline distributions. Three.js runs in the browser and DenchClaw's App Builder gives it direct access to your CRM data — making 3D data visualization of your pipeline surprisingly straightforward.

This guide builds a 3D force-directed network graph showing contacts and their deal relationships, rendered with Three.js.

What You're Building#

  • 3D force-directed graph with contacts as spheres and deals as connecting edges
  • Node size proportional to deal value
  • Color coding: lead (blue), qualified (green), customer (gold)
  • Interactive: click a node to see contact details
  • Mouse orbit controls to rotate and zoom

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/3d-data-viz.dench.app

.dench.yaml:

name: 3D Pipeline Viz
description: Three.js 3D visualization of contacts and deals
icon: box
version: 1.0.0
permissions:
  - read:crm
display: tab

Step 2: HTML with Three.js#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>3D Pipeline Viz</title>
  <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #050a15; overflow: hidden; }
    canvas { display: block; }
    #tooltip {
      position: fixed;
      background: rgba(15, 23, 42, 0.95);
      border: 1px solid #334155;
      border-radius: 10px;
      padding: 12px 16px;
      color: #e2e8f0;
      font-family: system-ui, sans-serif;
      font-size: 13px;
      pointer-events: none;
      display: none;
      max-width: 220px;
      backdrop-filter: blur(8px);
    }
    #legend {
      position: fixed;
      top: 16px;
      left: 16px;
      background: rgba(15, 23, 42, 0.8);
      border: 1px solid #334155;
      border-radius: 10px;
      padding: 12px;
      color: #94a3b8;
      font-family: system-ui, sans-serif;
      font-size: 12px;
      backdrop-filter: blur(8px);
    }
    .legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
    .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
    #loading { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); color: #64748b; font-family: system-ui; font-size: 14px; }
  </style>
</head>
<body>
  <div id="tooltip"></div>
  <div id="legend">
    <div class="legend-item"><div class="legend-dot" style="background:#6366f1"></div>Lead</div>
    <div class="legend-item"><div class="legend-dot" style="background:#10b981"></div>Qualified</div>
    <div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div>Customer</div>
    <div class="legend-item"><div class="legend-dot" style="background:#334155"></div>Deal edge</div>
    <div style="color:#475569;font-size:11px;margin-top:8px">Drag to orbit · Scroll to zoom</div>
  </div>
  <div id="loading">Loading CRM data...</div>
  <script src="viz.js"></script>
</body>
</html>

Step 3: Three.js Visualization#

viz.js:

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050a15);
scene.fog = new THREE.Fog(0x050a15, 200, 600);
 
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 150);
 
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
 
// Lighting
scene.add(new THREE.AmbientLight(0x334155, 0.6));
const pointLight = new THREE.PointLight(0x6366f1, 1.5, 300);
pointLight.position.set(0, 50, 50);
scene.add(pointLight);
const fillLight = new THREE.PointLight(0x10b981, 0.5, 300);
fillLight.position.set(-50, -30, -50);
scene.add(fillLight);
 
// Controls
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 30;
controls.maxDistance = 400;
 
// Raycaster for click detection
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const nodeObjects = [];
const nodeData = {};
 
const STATUS_COLORS = {
  'Lead': 0x6366f1,
  'Qualified': 0x10b981,
  'Customer': 0xf59e0b,
  'default': 0x64748b
};
 
async function loadAndVisualize() {
  const [contacts, deals] = await Promise.all([
    dench.db.query(`SELECT id, "Full Name", "Status", "Company", "Email Address" FROM v_people LIMIT 80`),
    dench.db.query(`SELECT id, "Deal Name", "Contact", "Company", "Value", "Stage" FROM v_deals WHERE "Stage" NOT IN ('Closed Lost') LIMIT 50`)
  ]);
 
  document.getElementById('loading').style.display = 'none';
 
  // Position nodes randomly in 3D space using force simulation approximation
  const positions = {};
  contacts.forEach((c, i) => {
    const phi = Math.acos(-1 + (2 * i) / contacts.length);
    const theta = Math.sqrt(contacts.length * Math.PI) * phi;
    const r = 60 + Math.random() * 30;
    positions[c.id] = new THREE.Vector3(
      r * Math.cos(theta) * Math.sin(phi),
      r * Math.sin(theta) * Math.sin(phi),
      r * Math.cos(phi)
    );
  });
 
  // Draw contact nodes
  contacts.forEach(contact => {
    const color = STATUS_COLORS[contact.Status] || STATUS_COLORS.default;
    const geometry = new THREE.SphereGeometry(2.5, 16, 16);
    const material = new THREE.MeshPhongMaterial({
      color,
      emissive: color,
      emissiveIntensity: 0.2,
      transparent: true,
      opacity: 0.9
    });
    const sphere = new THREE.Mesh(geometry, material);
    sphere.position.copy(positions[contact.id]);
    sphere.userData.contactId = contact.id;
    scene.add(sphere);
    nodeObjects.push(sphere);
    nodeData[contact.id] = contact;
  });
 
  // Draw deal edges (lines connecting contact to their deals)
  deals.forEach(deal => {
    if (!deal.Contact || !positions[deal.Contact]) return;
    
    // Find related company node position (or use offset)
    const dealPos = positions[deal.Contact].clone().add(
      new THREE.Vector3(
        (Math.random() - 0.5) * 20,
        (Math.random() - 0.5) * 20,
        (Math.random() - 0.5) * 20
      )
    );
 
    // Draw edge line
    const points = [positions[deal.Contact], dealPos];
    const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
    const lineMat = new THREE.LineBasicMaterial({ color: 0x334155, transparent: true, opacity: 0.4 });
    scene.add(new THREE.Line(lineGeo, lineMat));
 
    // Deal node (cube, sized by value)
    const value = Number(deal.Value || 0);
    const size = Math.max(1, Math.min(5, 1 + value / 20000));
    const dealGeo = new THREE.BoxGeometry(size, size, size);
    const dealMat = new THREE.MeshPhongMaterial({ color: 0x475569, emissive: 0x334155, emissiveIntensity: 0.3 });
    const dealMesh = new THREE.Mesh(dealGeo, dealMat);
    dealMesh.position.copy(dealPos);
    dealMesh.rotation.set(Math.random(), Math.random(), Math.random());
    dealMesh.userData.dealId = deal.id;
    dealMesh.userData.deal = deal;
    scene.add(dealMesh);
    nodeObjects.push(dealMesh);
  });
 
  // Add particle field background
  const particleCount = 300;
  const particleGeo = new THREE.BufferGeometry();
  const positions3 = new Float32Array(particleCount * 3);
  for (let i = 0; i < particleCount * 3; i++) positions3[i] = (Math.random() - 0.5) * 400;
  particleGeo.setAttribute('position', new THREE.BufferAttribute(positions3, 3));
  const particleMat = new THREE.PointsMaterial({ color: 0x1e293b, size: 0.8 });
  scene.add(new THREE.Points(particleGeo, particleMat));
}
 
// Tooltip on hover
renderer.domElement.addEventListener('mousemove', (e) => {
  mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  const hits = raycaster.intersectObjects(nodeObjects);
  const tooltip = document.getElementById('tooltip');
  
  if (hits.length > 0) {
    const obj = hits[0].object;
    renderer.domElement.style.cursor = 'pointer';
    let html = '';
    if (obj.userData.contactId) {
      const c = nodeData[obj.userData.contactId];
      html = `<strong>${c['Full Name'] || 'Unknown'}</strong><br>${c.Company || ''}<br><span style="color:#64748b">${c.Status} · ${c['Email Address'] || 'no email'}</span>`;
    } else if (obj.userData.deal) {
      const d = obj.userData.deal;
      html = `<strong>${d['Deal Name'] || 'Deal'}</strong><br>${d.Company || ''}<br><span style="color:#10b981">$${Number(d.Value || 0).toLocaleString()}</span> · ${d.Stage}`;
    }
    tooltip.innerHTML = html;
    tooltip.style.display = 'block';
    tooltip.style.left = (e.clientX + 16) + 'px';
    tooltip.style.top = (e.clientY - 10) + 'px';
  } else {
    renderer.domElement.style.cursor = 'default';
    tooltip.style.display = 'none';
  }
});
 
// Animation loop
function animate() {
  requestAnimationFrame(animate);
  controls.update();
  // Slowly rotate all deal nodes
  nodeObjects.filter(n => n.userData.dealId).forEach(n => { n.rotation.y += 0.005; });
  renderer.render(scene, camera);
}
 
// Handle resize
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});
 
loadAndVisualize();
animate();

Frequently Asked Questions#

Three.js OrbitControls isn't loading — how do I fix this?#

The OrbitControls import path changed in newer Three.js versions. For Three.js r160+, use the module version: import { OrbitControls } from 'three/addons/controls/OrbitControls.js'. If using CDN builds, match the version numbers exactly.

Can I visualize geographic data instead of a network graph?#

Yes. Three.js can render a globe (SphereGeometry mapped with a world texture) and plot contact locations as SphereGeometry nodes placed at latitude/longitude coordinates. Add a City or Country field to your contacts and map them geographically.

How do I make the nodes clickable to open the entry?#

In the click event handler, call dench.apps.navigate('/entry/' + obj.userData.contactId) to open the full CRM entry page for that contact.

Is Three.js too heavy for a widget?#

Yes. For widget mode, use a canvas 2D API or SVG-based approach. Three.js is best as a full-tab experience due to WebGL overhead.

Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA