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.
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: tabStep 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 →
