import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // --- Utility: throttle function --- function throttle(fn, limit) { let lastCall = 0; let timeout = null; return function (...args) { const now = Date.now(); const remaining = limit - (now - lastCall); if (remaining <= 0) { lastCall = now; fn.apply(this, args); } else if (!timeout) { timeout = setTimeout(() => { lastCall = Date.now(); timeout = null; fn.apply(this, args); }, remaining); } }; } // DOM references const dom = { container: null, btnAddNode: null, btnAddEdge: null, btnReset: null, btnDeleteSelected: null, btnConnectSelected: null, serverStatus: null, presenceCount: null, toastContainer: null }; // Graph data structure (synced via WebSocket) const graph = { nodes: [], edges: [] }; // Three.js objects let scene, camera, renderer, controls; let nodeMeshes = []; let edgeLines = []; let nodeLabels = []; // CSS2D or sprite labels let raycaster, mouse; let selectedNodes = []; // support multi-select (up to 2 for edge creation) // Shared geometry for all nodes (performance: avoid duplicating geometry) const sharedNodeGeometry = new THREE.SphereGeometry(0.4, 32, 32); // Drag state let isDragging = false; let draggedNode = null; let dragPlane = null; let dragOffset = new THREE.Vector3(); let mouseDownPos = new THREE.Vector2(); const CLICK_THRESHOLD = 3; // pixels — below this, treat as click not drag // WebSocket connection let ws = null; let wsConnected = false; // Throttled moveNode sender (30 msgs/sec max) const sendMoveThrottled = throttle((nodeId, x, y, z) => { sendToServer({ type: 'moveNode', node: { id: nodeId, x, y, z } }); }, 33); // Colors const colors = { node: 0x4ecdc4, nodeHover: 0xff6b6b, nodeSelected: 0xffe66d, nodeSelectedSecond: 0xff9f43, edge: 0x95a5a6, background: 0x1a1a2e }; // --- Toast notification system --- function showToast(message, type = 'info', duration = 3000) { if (!dom.toastContainer) return; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; dom.toastContainer.appendChild(toast); // Trigger animation requestAnimationFrame(() => toast.classList.add('toast-visible')); setTimeout(() => { toast.classList.remove('toast-visible'); toast.addEventListener('transitionend', () => toast.remove()); }, duration); } // Initialize the visualizer function initDOM() { dom.container = document.getElementById('visualizer-container'); dom.btnAddNode = document.getElementById('btn-add-node'); dom.btnAddEdge = document.getElementById('btn-add-edge'); dom.btnReset = document.getElementById('btn-reset'); dom.btnDeleteSelected = document.getElementById('btn-delete-selected'); dom.btnConnectSelected = document.getElementById('btn-connect-selected'); dom.serverStatus = document.getElementById('server-status'); dom.presenceCount = document.getElementById('presence-count'); // Create toast container dom.toastContainer = document.createElement('div'); dom.toastContainer.id = 'toast-container'; document.body.appendChild(dom.toastContainer); } // Initialize the visualizer function init() { initDOM(); if (!dom.container) { console.error('Visualizer container not found'); return; } const width = dom.container.clientWidth; const height = dom.container.clientHeight; // Scene scene = new THREE.Scene(); scene.background = new THREE.Color(colors.background); // Camera camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000); camera.position.set(0, 0, 15); // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio); dom.container.appendChild(renderer.domElement); // Controls controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.rotateSpeed = 0.5; // Raycaster for mouse interaction raycaster = new THREE.Raycaster(); mouse = new THREE.Vector2(); // Lighting const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const pointLight = new THREE.PointLight(0xffffff, 1); pointLight.position.set(10, 10, 10); scene.add(pointLight); // Add background elements for depth perception addGridPlane(); addAxisLines(); addStarfield(); // Add fog for depth (starts closer for more visible effect) scene.fog = new THREE.Fog(colors.background, 10, 35); // Graph will be populated from server via WebSocket // Drag plane (invisible, used for projecting mouse to 3D) dragPlane = new THREE.Plane(); // Event listeners window.addEventListener('resize', onWindowResize); dom.container.addEventListener('mousedown', onMouseDown); dom.container.addEventListener('mousemove', onMouseMove); dom.container.addEventListener('mouseup', onMouseUp); dom.container.addEventListener('mouseleave', onMouseUp); // Button controls dom.btnAddNode.addEventListener('click', addRandomNode); dom.btnAddEdge.addEventListener('click', addRandomEdge); dom.btnReset.addEventListener('click', resetGraph); if (dom.btnDeleteSelected) dom.btnDeleteSelected.addEventListener('click', deleteSelectedNode); if (dom.btnConnectSelected) dom.btnConnectSelected.addEventListener('click', connectSelectedNodes); // Keyboard shortcuts window.addEventListener('keydown', onKeyDown); // Connect to WebSocket for sync connectWebSocket(); // Start animation loop animate(); } // WebSocket connection for CRDT synchronization function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws`; console.log('Connecting to WebSocket:', wsUrl); ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('WebSocket connected - waiting for graph sync'); wsConnected = true; updateConnectionStatus(true); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); handleServerMessage(msg); } catch (err) { console.error('Error parsing WebSocket message:', err); } }; ws.onclose = () => { console.log('WebSocket disconnected'); wsConnected = false; updateConnectionStatus(false); // Reconnect after delay setTimeout(connectWebSocket, 3000); }; ws.onerror = (err) => { console.error('WebSocket error:', err); }; } function updateConnectionStatus(connected) { if (!dom.serverStatus) return; dom.serverStatus.textContent = connected ? 'Connected - Collaborative mode' : 'Disconnected - Reconnecting...'; dom.serverStatus.style.color = connected ? '#4ecdc4' : '#ff6b6b'; } function updatePresenceCount(count) { if (!dom.presenceCount) return; dom.presenceCount.textContent = `${count} user${count !== 1 ? 's' : ''} online`; } // Handle messages from server function handleServerMessage(msg) { switch (msg.type) { case 'fullSync': if (msg.graph) { syncFullGraph(msg.graph); } break; case 'addNode': if (msg.node) { addNodeLocal(msg.node.id, msg.node.x, msg.node.y, msg.node.z); showToast(`Node ${msg.node.id} added`, 'success', 2000); } break; case 'addEdge': if (msg.edge) { addEdgeLocal(msg.edge.from, msg.edge.to); } break; case 'removeEdge': if (msg.edge) { removeEdgeLocal(msg.edge.from, msg.edge.to); } break; case 'moveNode': if (msg.node) { moveNodeLocal(msg.node.id, msg.node.x, msg.node.y, msg.node.z); } break; case 'connectionCount': updatePresenceCount(msg.count); break; case 'error': showToast(msg.message || 'Server error', 'error'); break; } } // Sync entire graph from server function syncFullGraph(serverGraph) { console.log('Syncing full graph:', serverGraph.nodes?.length || 0, 'nodes,', serverGraph.edges?.length || 0, 'edges'); // Dispose old Three.js resources to prevent GPU memory leaks nodeMeshes.forEach(mesh => { scene.remove(mesh); mesh.material.dispose(); // geometry is shared, don't dispose it }); edgeLines.forEach(line => { scene.remove(line); line.geometry.dispose(); line.material.dispose(); }); nodeLabels.forEach(label => { scene.remove(label); if (label.material) { if (label.material.map) label.material.map.dispose(); label.material.dispose(); } }); nodeMeshes = []; edgeLines = []; nodeLabels = []; graph.nodes = []; graph.edges = []; selectedNodes = []; updateSelectionUI(); // Rebuild from server state if (serverGraph.nodes) { serverGraph.nodes.forEach(node => { addNodeLocal(node.id, node.x, node.y, node.z); }); } if (serverGraph.edges) { serverGraph.edges.forEach(edge => { addEdgeLocal(edge.from, edge.to); }); } } // Send operation to server function sendToServer(msg) { if (ws && wsConnected) { ws.send(JSON.stringify(msg)); } else { console.warn('WebSocket not connected, cannot send:', msg); } } function addGridPlane() { // Create a visible grid for spatial reference const gridSize = 20; const gridDivisions = 20; const gridColor1 = 0x4a4a6a; // brighter grid lines const gridColor2 = 0x3a3a5a; // Floor grid const grid = new THREE.GridHelper(gridSize, gridDivisions, gridColor1, gridColor2); grid.position.y = -5; scene.add(grid); // Back wall grid const grid2 = new THREE.GridHelper(gridSize, gridDivisions, gridColor1, gridColor2); grid2.position.z = -10; grid2.rotation.x = Math.PI / 2; scene.add(grid2); // Left wall grid const grid3 = new THREE.GridHelper(gridSize, gridDivisions, gridColor1, gridColor2); grid3.position.x = -10; grid3.rotation.z = Math.PI / 2; scene.add(grid3); } function addAxisLines() { // X axis - red const xPoints = [new THREE.Vector3(-10, -5, -10), new THREE.Vector3(10, -5, -10)]; const xGeom = new THREE.BufferGeometry().setFromPoints(xPoints); const xMat = new THREE.LineBasicMaterial({ color: 0xff4444 }); scene.add(new THREE.Line(xGeom, xMat)); // Y axis - green const yPoints = [new THREE.Vector3(-10, -5, -10), new THREE.Vector3(-10, 10, -10)]; const yGeom = new THREE.BufferGeometry().setFromPoints(yPoints); const yMat = new THREE.LineBasicMaterial({ color: 0x44ff44 }); scene.add(new THREE.Line(yGeom, yMat)); // Z axis - blue const zPoints = [new THREE.Vector3(-10, -5, -10), new THREE.Vector3(-10, -5, 10)]; const zGeom = new THREE.BufferGeometry().setFromPoints(zPoints); const zMat = new THREE.LineBasicMaterial({ color: 0x4444ff }); scene.add(new THREE.Line(zGeom, zMat)); } function addStarfield() { // Create a visible starfield background const starCount = 300; const starGeometry = new THREE.BufferGeometry(); const starPositions = new Float32Array(starCount * 3); for (let i = 0; i < starCount * 3; i += 3) { // Distribute stars in a sphere around the scene const radius = 20 + Math.random() * 15; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); starPositions[i] = radius * Math.sin(phi) * Math.cos(theta); starPositions[i + 1] = radius * Math.sin(phi) * Math.sin(theta); starPositions[i + 2] = radius * Math.cos(phi); } starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)); const starMaterial = new THREE.PointsMaterial({ color: 0xaaaacc, size: 0.15, sizeAttenuation: true }); const stars = new THREE.Points(starGeometry, starMaterial); scene.add(stars); } // Create a text sprite for node labels function createTextSprite(text) { const canvas = document.createElement('canvas'); const size = 128; canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.font = 'Bold 48px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Text with outline for readability ctx.strokeStyle = 'rgba(0,0,0,0.8)'; ctx.lineWidth = 6; ctx.strokeText(text, size / 2, size / 2); ctx.fillStyle = '#ffffff'; ctx.fillText(text, size / 2, size / 2); const texture = new THREE.CanvasTexture(canvas); const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); const sprite = new THREE.Sprite(material); sprite.scale.set(0.6, 0.6, 1); return sprite; } // Local-only functions (apply server state to visualization) function addNodeLocal(id, x, y, z) { // Ensure we don't duplicate if (graph.nodes.find(n => n.id === id)) return; // Create node data graph.nodes.push({ id, x, y, z }); // Create visual representation (shared geometry for performance) const material = new THREE.MeshPhongMaterial({ color: colors.node, shininess: 100 }); const mesh = new THREE.Mesh(sharedNodeGeometry, material); mesh.position.set(x, y, z); mesh.userData.nodeId = id; scene.add(mesh); nodeMeshes.push(mesh); // Add floating label const label = createTextSprite(String(id)); label.position.set(x, y + 0.7, z); label.userData.nodeId = id; scene.add(label); nodeLabels.push(label); // Flash effect for new nodes material.emissive = new THREE.Color(0x4ecdc4); setTimeout(() => { material.emissive = new THREE.Color(0x000000); }, 300); } function addEdgeLocal(fromId, toId) { if (fromId === toId) return; const fromNode = graph.nodes.find(n => n.id === fromId); const toNode = graph.nodes.find(n => n.id === toId); if (!fromNode || !toNode) return; // Check if edge already exists const exists = graph.edges.some(e => (e.from === fromId && e.to === toId) || (e.from === toId && e.to === fromId) ); if (exists) return; // Add edge data graph.edges.push({ from: fromId, to: toId }); // Create visual representation const points = [ new THREE.Vector3(fromNode.x, fromNode.y, fromNode.z), new THREE.Vector3(toNode.x, toNode.y, toNode.z) ]; const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color: colors.edge, linewidth: 2 }); const line = new THREE.Line(geometry, material); line.userData.fromId = fromId; line.userData.toId = toId; scene.add(line); edgeLines.push(line); } function removeEdgeLocal(fromId, toId) { // Remove from data const idx = graph.edges.findIndex(e => (e.from === fromId && e.to === toId) || (e.from === toId && e.to === fromId) ); if (idx < 0) return; graph.edges.splice(idx, 1); // Remove visual const lineIdx = edgeLines.findIndex(l => (l.userData.fromId === fromId && l.userData.toId === toId) || (l.userData.fromId === toId && l.userData.toId === fromId) ); if (lineIdx >= 0) { const line = edgeLines[lineIdx]; scene.remove(line); line.geometry.dispose(); line.material.dispose(); edgeLines.splice(lineIdx, 1); } } function moveNodeLocal(id, x, y, z) { // Update graph data const node = graph.nodes.find(n => n.id === id); if (!node) return; node.x = x; node.y = y; node.z = z; // Update mesh position const mesh = nodeMeshes.find(m => m.userData.nodeId === id); if (mesh) { mesh.position.set(x, y, z); } // Update label position const label = nodeLabels.find(l => l.userData.nodeId === id); if (label) { label.position.set(x, y + 0.7, z); } // Update connected edges updateEdgesForNode(id); } function updateEdgesForNode(nodeId) { edgeLines.forEach(line => { const fromId = line.userData.fromId; const toId = line.userData.toId; if (fromId === nodeId || toId === nodeId) { const fromNode = graph.nodes.find(n => n.id === fromId); const toNode = graph.nodes.find(n => n.id === toId); if (fromNode && toNode) { const positions = line.geometry.attributes.position; positions.setXYZ(0, fromNode.x, fromNode.y, fromNode.z); positions.setXYZ(1, toNode.x, toNode.y, toNode.z); positions.needsUpdate = true; } } }); } // Server-facing functions (send operations to server) function requestAddNode(x, y, z) { sendToServer({ type: 'addNode', node: { x, y, z } }); } function requestAddEdge(fromId, toId) { sendToServer({ type: 'addEdge', edge: { from: fromId, to: toId } }); } function requestRemoveNode(id) { sendToServer({ type: 'removeNode', node: { id } }); } function requestRemoveEdge(fromId, toId) { sendToServer({ type: 'removeEdge', edge: { from: fromId, to: toId } }); } // Selection management (supports 1-2 nodes for edge creation) function clearSelection() { selectedNodes.forEach(mesh => { mesh.material.color.setHex(colors.node); }); selectedNodes = []; updateSelectionUI(); } function toggleNodeSelection(mesh) { const idx = selectedNodes.indexOf(mesh); if (idx >= 0) { // Deselect mesh.material.color.setHex(colors.node); selectedNodes.splice(idx, 1); } else { if (selectedNodes.length >= 2) { // Deselect oldest selectedNodes[0].material.color.setHex(colors.node); selectedNodes.shift(); } selectedNodes.push(mesh); mesh.material.color.setHex( selectedNodes.length === 1 ? colors.nodeSelected : colors.nodeSelectedSecond ); // Re-color first if we now have two if (selectedNodes.length === 2) { selectedNodes[0].material.color.setHex(colors.nodeSelected); selectedNodes[1].material.color.setHex(colors.nodeSelectedSecond); } } updateSelectionUI(); } function updateSelectionUI() { const hasSelection = selectedNodes.length > 0; const hasPair = selectedNodes.length === 2; if (dom.btnDeleteSelected) { dom.btnDeleteSelected.disabled = !hasSelection; dom.btnDeleteSelected.textContent = hasSelection ? `Delete Node ${selectedNodes.map(m => m.userData.nodeId).join(', ')}` : 'Delete Selected'; } if (dom.btnConnectSelected) { dom.btnConnectSelected.disabled = !hasPair; dom.btnConnectSelected.textContent = hasPair ? `Connect ${selectedNodes[0].userData.nodeId} ↔ ${selectedNodes[1].userData.nodeId}` : 'Connect Two Nodes'; } } function deleteSelectedNode() { if (selectedNodes.length === 0) { showToast('Select a node first', 'info'); return; } // Delete all selected nodes selectedNodes.forEach(mesh => { requestRemoveNode(mesh.userData.nodeId); }); selectedNodes = []; updateSelectionUI(); } function connectSelectedNodes() { if (selectedNodes.length !== 2) { showToast('Select exactly 2 nodes to connect', 'info'); return; } const idA = selectedNodes[0].userData.nodeId; const idB = selectedNodes[1].userData.nodeId; requestAddEdge(idA, idB); clearSelection(); } function onKeyDown(event) { // Delete or Backspace to remove selected node(s) if (event.key === 'Delete' || event.key === 'Backspace') { // Don't intercept if user is typing in an input if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; if (selectedNodes.length > 0) { event.preventDefault(); deleteSelectedNode(); } } // Escape to clear selection if (event.key === 'Escape') { clearSelection(); } } // UI button handlers function addRandomNode() { const range = 5; const x = (Math.random() - 0.5) * range * 2; const y = (Math.random() - 0.5) * range * 2; const z = (Math.random() - 0.5) * range * 2; // Remember current node count to detect when server confirms the new node const prevCount = graph.nodes.length; const randomTarget = graph.nodes.length > 0 ? graph.nodes[Math.floor(Math.random() * graph.nodes.length)].id : null; requestAddNode(x, y, z); // Wait for confirmation then add edge, with timeout safety if (randomTarget !== null) { const startTime = Date.now(); const checkInterval = setInterval(() => { if (graph.nodes.length > prevCount) { clearInterval(checkInterval); const newNodeId = graph.nodes[graph.nodes.length - 1].id; if (newNodeId !== randomTarget) { requestAddEdge(newNodeId, randomTarget); } } else if (Date.now() - startTime > 2000) { clearInterval(checkInterval); // give up after 2s } }, 50); } } function addRandomEdge() { if (graph.nodes.length < 2) { showToast('Need at least 2 nodes', 'info'); return; } // Safe random selection without infinite loop const fromIdx = Math.floor(Math.random() * graph.nodes.length); let toIdx = Math.floor(Math.random() * (graph.nodes.length - 1)); if (toIdx >= fromIdx) toIdx++; // skip the fromIdx slot requestAddEdge(graph.nodes[fromIdx].id, graph.nodes[toIdx].id); } function resetGraph() { sendToServer({ type: 'reset' }); showToast('Graph reset', 'info'); } function onWindowResize() { const width = dom.container.clientWidth; const height = dom.container.clientHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); } function onMouseDown(event) { const rect = dom.container.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; mouseDownPos.set(event.clientX, event.clientY); raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(nodeMeshes); if (intersects.length > 0) { isDragging = true; draggedNode = intersects[0].object; controls.enabled = false; // Create a drag plane facing the camera at the node's position const cameraDirection = new THREE.Vector3(); camera.getWorldDirection(cameraDirection); dragPlane = new THREE.Plane(); dragPlane.setFromNormalAndCoplanarPoint(cameraDirection, draggedNode.position); // Compute offset between intersection point and node center so it doesn't snap const intersection = new THREE.Vector3(); raycaster.ray.intersectPlane(dragPlane, intersection); dragOffset.subVectors(draggedNode.position, intersection); dom.container.style.cursor = 'grabbing'; } } function onMouseMove(event) { const rect = dom.container.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; if (isDragging && draggedNode) { raycaster.setFromCamera(mouse, camera); const intersection = new THREE.Vector3(); if (raycaster.ray.intersectPlane(dragPlane, intersection)) { intersection.add(dragOffset); const nodeId = draggedNode.userData.nodeId; moveNodeLocal(nodeId, intersection.x, intersection.y, intersection.z); // Broadcast to other clients (throttled to ~30 msgs/sec) sendMoveThrottled(nodeId, intersection.x, intersection.y, intersection.z); } dom.container.style.cursor = 'grabbing'; return; } // Hover logic (only when not dragging) raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(nodeMeshes); // Reset all non-selected nodes to default color nodeMeshes.forEach(mesh => { if (!selectedNodes.includes(mesh)) { mesh.material.color.setHex(colors.node); } }); // Highlight hovered node if (intersects.length > 0 && !selectedNodes.includes(intersects[0].object)) { intersects[0].object.material.color.setHex(colors.nodeHover); dom.container.style.cursor = 'pointer'; } else { dom.container.style.cursor = 'grab'; } } function onMouseUp(event) { if (isDragging && draggedNode) { // Check if it was a click (barely moved) vs a real drag const dx = event.clientX - mouseDownPos.x; const dy = event.clientY - mouseDownPos.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < CLICK_THRESHOLD) { // Treat as click — toggle selection on this node toggleNodeSelection(draggedNode); console.log('Selected nodes:', selectedNodes.map(m => m.userData.nodeId)); } isDragging = false; draggedNode = null; controls.enabled = true; dom.container.style.cursor = 'grab'; } } function animate() { requestAnimationFrame(animate); controls.update(); // Gentle animation - nodes slowly pulse const time = Date.now() * 0.001; nodeMeshes.forEach((mesh, i) => { const scale = 1 + Math.sin(time + i * 0.5) * 0.05; mesh.scale.set(scale, scale, scale); }); renderer.render(scene, camera); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Export for potential external use export { graph, requestAddNode, requestAddEdge, requestRemoveNode, requestRemoveEdge, resetGraph };