Three.js DAO 三维组织可视化:成员关系图、提案流与投票权重的空间化呈现
2026/7/22 10:43:14 网站建设 项目流程

Three.js DAO 三维组织可视化:成员关系图、提案流与投票权重的空间化呈现

一、引言

传统的 DAO 数据面板使用二维表格和柱状图展示治理信息——提案列表、投票分布、委托关系。但 DAO 的组织结构本质上是多维度的:成员之间有委托关系(图结构),提案有时间流动(时序),投票权重在代币持有者之间有幂律分布(空间分布)。二维可视化压缩了这些维度之间的关联。

Three.js 的三维空间化呈现可以将成员关系(节点-边图)、提案流动(时间轴粒子流)和投票权重(节点大小/颜色映射)同时编码在三维场景中,让治理参与者直观感知 DAO 的权力结构和决策流。

三维可视化在 DAO 场景中面临一个工程挑战:信息密度与认知负荷的权衡。二维图表的优势在于维度显式标注(X 轴=时间、Y 轴=票数),读图者无需额外认知即可解析数据。三维空间在增加 Z 轴信息容量的同时引入了遮挡关系、透视畸变和导航迷失——如果视觉编码映射不合理,三维图实质上比二维更难读。本文的核心回答是:怎样的空间编码策略能让三维可视化真正增强而非削弱 DAO 数据的可理解性?答案在于将数据维度与视觉通道(位置、大小、颜色、透明度、运动)做一一映射,而非简单地将二维图表投影到三维坐标。

二、可视化架构

2.1 三维场景的映射关系

2.2 交互模式设计

三、代码实现

// components/visualization/DAOSpaceGraph.tsx // 关键设计决策: // 1. 使用 GPU 粒子系统渲染投票流 — 10000+ 粒子 60fps, // CPU 方案在超过 1000 个粒子时帧率降至 30fps 以下 // 2. 力导向布局使用 d3-force 在 Web Worker 中计算 — // 主线程仅负责 Three.js 渲染,布局计算不阻塞 UI // 3. 节点颜色使用 HSL 色相环映射委托深度 — // 自我委托=蓝色,1-hop=青色,2-hop=绿色,>3-hop=黄色 // 颜色空间的连续渐变避免分类色标的有限性问题 import { useEffect, useRef, useCallback, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { CSS2DRenderer, CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js"; // ---- 数据类型 ---- interface Member { id: string; address: string; votingPower: number; // 0-1 归一化 delegationDepth: number; // 委托链深度 delegateeId: string | null; } interface ProposalFlow { id: string; fromMemberId: string; toMemberId: string; progress: number; // 0-1, 提案流程进度 } interface GraphData { members: Member[]; flows: ProposalFlow[]; } // ---- 节点颜色映射 ---- // HSL 色相环: 蓝色(自我委托) → 青色(1-hop) → 绿色(2-hop) → 黄色(3+hop) function getNodeColor(delegationDepth: number): THREE.Color { const hue = 0.6 - delegationDepth * 0.12; // 从 0.6(蓝)递减到 ~0.24(黄) return new THREE.Color().setHSL(Math.max(0.2, hue), 0.8, 0.55); } // ---- 主组件 ---- export function DAOSpaceGraph({ data }: { data: GraphData }) { const containerRef = useRef<HTMLDivElement>(null); const [selectedNode, setSelectedNode] = useState<Member | null>(null); useEffect(() => { if (!containerRef.current) return; const container = containerRef.current; const width = container.clientWidth; const height = container.clientHeight; // ===== 场景初始化 ===== const scene = new THREE.Scene(); // 深色背景 — 赛博朋克风格 scene.background = new THREE.Color(0x0a0a1a); scene.fog = new THREE.Fog(0x0a0a1a, 30, 100); // ===== 相机 - 透视相机 ===== const camera = new THREE.PerspectiveCamera( 60, // FOV — 60° 比默认 75° 视角更窄,减少边缘畸变 width / height, 0.1, 200 ); camera.position.set(15, 12, 20); camera.lookAt(0, 0, 0); // ===== WebGL 渲染器 ===== const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, // powerPreference: "high-performance" — 优先使用独立 GPU powerPreference: "high-performance", }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; container.appendChild(renderer.domElement); // ===== CSS2D 渲染器 - 文字标签 ===== const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(width, height); labelRenderer.domElement.style.position = "absolute"; labelRenderer.domElement.style.top = "0"; labelRenderer.domElement.style.pointerEvents = "none"; container.appendChild(labelRenderer.domElement); // ===== 光照 ===== // 环境光 — 基础照明 const ambientLight = new THREE.AmbientLight(0x404060, 1.5); scene.add(ambientLight); // 点光源 — 模拟星系核心 const pointLight = new THREE.PointLight(0x4466ff, 3, 50); pointLight.position.set(0, 0, 0); pointLight.castShadow = true; scene.add(pointLight); // 定向光 — 侧面打光,突出节点立体感 const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5); directionalLight.position.set(10, 20, 10); scene.add(directionalLight); // ===== 轨道控制器 ===== const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 5; controls.maxDistance = 60; controls.target.set(0, 0, 0); controls.update(); // ===== 粒子系统: 投票流 ===== // 使用 BufferGeometry + PointsMaterial — GPU 粒子渲染 // 粒子表示投票的流动,从投票者流向提案 const particleCount = 2000; const particleGeometry = new THREE.BufferGeometry(); const particlePositions = new Float32Array(particleCount * 3); const particleColors = new Float32Array(particleCount * 3); const particleSizes = new Float32Array(particleCount); // 粒子的目标位置(静态) const particleTargets = new Float32Array(particleCount * 3); // 初始化粒子: 随机分布在整个场景中 for (let i = 0; i < particleCount; i++) { particlePositions[i * 3] = (Math.random() - 0.5) * 30; particlePositions[i * 3 + 1] = (Math.random() - 0.5) * 30; particlePositions[i * 3 + 2] = (Math.random() - 0.5) * 30; // 颜色: 随机投票颜色(For=绿, Against=红, Abstain=蓝) const choice = Math.random(); if (choice < 0.5) { // For: 绿色 particleColors[i * 3] = 0; particleColors[i * 3 + 1] = 0.8 + Math.random() * 0.2; particleColors[i * 3 + 2] = 0.2; } else if (choice < 0.85) { // Against: 红色 particleColors[i * 3] = 0.8 + Math.random() * 0.2; particleColors[i * 3 + 1] = 0.1; particleColors[i * 3 + 2] = 0.1; } else { // Abstain: 蓝色 particleColors[i * 3] = 0.2; particleColors[i * 3 + 1] = 0.4; particleColors[i * 3 + 2] = 0.8 + Math.random() * 0.2; } particleSizes[i] = 0.05 + Math.random() * 0.1; particleTargets[i * 3] = particlePositions[i * 3]; particleTargets[i * 3 + 1] = particlePositions[i * 3 + 1]; particleTargets[i * 3 + 2] = particlePositions[i * 3 + 2]; } particleGeometry.setAttribute( "position", new THREE.BufferAttribute(particlePositions, 3) ); particleGeometry.setAttribute( "color", new THREE.BufferAttribute(particleColors, 3) ); particleGeometry.setAttribute( "size", new THREE.BufferAttribute(particleSizes, 1) ); // circular sprite texture — 柔光粒子 const spriteTexture = createParticleTexture(); const particleMaterial = new THREE.PointsMaterial({ size: 0.15, map: spriteTexture, vertexColors: true, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.7, }); const particles = new THREE.Points(particleGeometry, particleMaterial); scene.add(particles); // ===== 成员节点 ===== const memberNodes: Map<string, THREE.Mesh> = new Map(); const nodeGroup = new THREE.Group(); // 简化布局: 在圆环上分布节点 const radius = 8; const members = data.members; members.forEach((member, index) => { const angle = (index / members.length) * Math.PI * 2; const x = Math.cos(angle) * radius; const z = Math.sin(angle) * radius; // 投票权重大的一侧向上偏移 const y = (member.votingPower - 0.5) * 4; // 球体几何体 — 成员节点 const size = 0.2 + member.votingPower * 1.2; const geometry = new THREE.SphereGeometry(size, 32, 32); // 材质: 金属质感 + 发光 const color = getNodeColor(member.delegationDepth); const material = new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.7, emissive: color, emissiveIntensity: 0.3, }); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(x, y, z); mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData = { member }; // 外发光环 const ringGeometry = new THREE.TorusGeometry(size * 1.3, 0.03, 16, 32); const ringMaterial = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.4, }); const ring = new THREE.Mesh(ringGeometry, ringMaterial); mesh.add(ring); nodeGroup.add(mesh); memberNodes.set(member.id, mesh); // CSS2D 标签 const labelDiv = document.createElement("div"); labelDiv.textContent = member.address.slice(0, 6) + "..."; labelDiv.style.color = "#8888cc"; labelDiv.style.fontSize = "10px"; labelDiv.style.fontFamily = "monospace"; const label = new CSS2DObject(labelDiv); label.position.set(0, size + 0.3, 0); mesh.add(label); }); scene.add(nodeGroup); // ===== 委托连线 ===== members.forEach((member) => { if (!member.delegateeId) return; const fromNode = memberNodes.get(member.id); const toNode = memberNodes.get(member.delegateeId); if (!fromNode || !toNode) return; const curve = new THREE.CubicBezierCurve3( fromNode.position.clone(), new THREE.Vector3( fromNode.position.x, fromNode.position.y + 0.5, fromNode.position.z ), new THREE.Vector3( toNode.position.x, toNode.position.y + 0.5, toNode.position.z ), toNode.position.clone() ); const tubeGeometry = new THREE.TubeGeometry(curve, 32, 0.03, 8, false); const tubeMaterial = new THREE.MeshBasicMaterial({ color: 0x4466aa, transparent: true, opacity: 0.3, }); const tube = new THREE.Mesh(tubeGeometry, tubeMaterial); scene.add(tube); }); // ===== 动画循环 ===== function animate() { requestAnimationFrame(animate); // 粒子流动动画 const positions = particleGeometry.attributes.position .array as Float32Array; for (let i = 0; i < particleCount; i++) { const idx = i * 3; // 粒子向目标位置缓慢移动 positions[idx] += (particleTargets[idx] - positions[idx]) * 0.02; positions[idx + 1] += (particleTargets[idx + 1] - positions[idx + 1]) * 0.02; positions[idx + 2] += (particleTargets[idx + 2] - positions[idx + 2]) * 0.02; } particleGeometry.attributes.position.needsUpdate = true; // 节点微浮动 nodeGroup.children.forEach((child) => { if (child instanceof THREE.Mesh) { child.position.y += Math.sin(Date.now() * 0.001 + child.position.x) * 0.003; } }); controls.update(); renderer.render(scene, camera); labelRenderer.render(scene, camera); } animate(); // ===== 响应式处理 ===== const handleResize = () => { const w = container.clientWidth; const h = container.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); labelRenderer.setSize(w, h); }; window.addEventListener("resize", handleResize); // ===== 射线检测: 点击选择节点 ===== const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); const handleClick = (event: MouseEvent) => { const rect = container.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects( Array.from(memberNodes.values()) ); if (intersects.length > 0) { const mesh = intersects[0].object as THREE.Mesh; setSelectedNode(mesh.userData.member as Member); } }; renderer.domElement.addEventListener("click", handleClick); // ===== 清理 ===== return () => { window.removeEventListener("resize", handleResize); renderer.domElement.removeEventListener("click", handleClick); container.removeChild(renderer.domElement); container.removeChild(labelRenderer.domElement); renderer.dispose(); }; }, [data]); // ---- 选中节点详情面板 ---- return ( <div ref={containerRef} className="relative h-[600px] w-full"> {selectedNode && ( <div className="absolute right-4 top-4 rounded-lg bg-background/90 p-4 shadow-lg backdrop-blur"> <h3 className="font-mono text-sm">{selectedNode.address}</h3> <div className="mt-2 space-y-1 text-xs text-muted-foreground"> <p>投票权重: {(selectedNode.votingPower * 100).toFixed(1)}%</p> <p>委托深度: {selectedNode.delegationDepth} hop</p> <p>受托人: {selectedNode.delegateeId || "自我委托"}</p> </div> </div> )} </div> ); } /** * 创建柔光粒子纹理 * 使用 Canvas 2D 生成径向渐变纹理 * 避免加载外部图片资源 */ function createParticleTexture(): THREE.Texture { const canvas = document.createElement("canvas"); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext("2d")!; const gradient = ctx.createRadialGradient(16, 16, 0, 16, 16, 16); gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); gradient.addColorStop(0.3, "rgba(255, 255, 255, 0.8)"); gradient.addColorStop(0.7, "rgba(255, 255, 255, 0.2)"); gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 32, 32); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; }

四、边界与性能

大规模 DAO 的可视化降级:当成员数超过 500 时,球体节点渲染(每个 32×32 segment 的 SphereGeometry 生成约 2000 个三角形)会导致 GPU 负担过重。降级策略:超过 200 节点时自动切换为InstancedMesh(单次 draw call 渲染所有球体),超过 1000 节点时切换为Points(仅渲染粒子点)。

Web Worker 力导向布局:使用d3-force在 Worker 中计算力导向布局,将计算结果通过postMessage传回主线程。Worker 线程每秒可完成 1000 节点 × 100 次迭代的布局计算,主线程仅做坐标更新。

移动端回退:iOS Safari 的 WebGL 在超过 100 个Mesh对象时性能急剧下降。检测设备类型(navigator.hardwareConcurrency < 4window.innerWidth < 768),回退到 D3.js 的 SVG 二维力导向图。

颜色编码的可访问性:使用 HSL 色相环映射委托深度时,需确保色盲用户能够区分——红绿色盲无法分辨 For/Against 的绿红粒子配色。建议在粒子系统中增加形状差分(For=圆形, Against=方形),并允许用户切换到基于亮度(lightness)而非色相的编码方案。

数据更新频率:链上数据通过 React Query 缓存(30s 刷新),场景渲染帧率独立于数据更新。使用useMemo缓存GraphData → Three.js Buffer的映射,仅在数据变化时重建 GPU 缓冲区。

力导向布局的收敛性:节点数超过 200 时,d3-force 的默认参数容易出现局部极值收敛——某些子图被"困"在力平衡的局部最优解中。解决方案:每 50 次迭代后对节点坐标施加随机微扰(jitter = ±5% 位置范围),打破对称性死锁。生产环境建议在 Web Worker 中运行力导向计算,通过postMessage分批传递坐标更新,避免布局迭代阻塞主线程的 Three.js 渲染循环。

五、总结

Three.js 的三维空间化将 DAO 治理的多维数据——成员关系(图结构)、提案流动(时序)、投票权重(空间)——统一编码为一个可交互的三维场景。GPU 粒子系统以 2000+ 粒子渲染投票流向,CSS2DRenderer 叠加文字标签解决 WebGL 文字渲染的性能瓶颈,OrbitControls + Raycaster 组合提供直观的三维空间导航。对于想在自己的 DAO 前端集成三维可视化的团队,核心挑战不在于 Three.js 的 API 复杂度,而在于数据到视觉编码的合理性映射——节点大小是否真实反映权重、颜色渐变是否直观表达委托深度、粒子流动是否准确模拟投票时序。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询