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