diff options
Diffstat (limited to 'js/visualizer.js')
| -rw-r--r-- | js/visualizer.js | 550 |
1 files changed, 472 insertions, 78 deletions
diff --git a/js/visualizer.js b/js/visualizer.js index b505c59..bfde28f 100644 --- a/js/visualizer.js +++ b/js/visualizer.js @@ -1,7 +1,40 @@ import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-// Graph data structure (synced via CRDT)
+// --- 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: []
@@ -11,32 +44,86 @@ const graph = { let scene, camera, renderer, controls;
let nodeMeshes = [];
let edgeLines = [];
+let nodeLabels = []; // CSS2D or sprite labels
let raycaster, mouse;
-let selectedNode = null;
+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() {
- const container = document.getElementById('visualizer-container');
- if (!container) {
+ initDOM();
+
+ if (!dom.container) {
console.error('Visualizer container not found');
return;
}
- const width = container.clientWidth;
- const height = container.clientHeight;
+ const width = dom.container.clientWidth;
+ const height = dom.container.clientHeight;
// Scene
scene = new THREE.Scene();
@@ -50,7 +137,7 @@ function init() { renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
- container.appendChild(renderer.domElement);
+ dom.container.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
@@ -80,17 +167,27 @@ function init() { // 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);
- container.addEventListener('click', onMouseClick);
- container.addEventListener('mousemove', onMouseMove);
+ dom.container.addEventListener('mousedown', onMouseDown);
+ dom.container.addEventListener('mousemove', onMouseMove);
+ dom.container.addEventListener('mouseup', onMouseUp);
+ dom.container.addEventListener('mouseleave', onMouseUp);
// Button controls
- document.getElementById('btn-add-node').addEventListener('click', addRandomNode);
- document.getElementById('btn-add-edge').addEventListener('click', addRandomEdge);
- document.getElementById('btn-reset').addEventListener('click', resetGraph);
+ 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);
- // Connect to WebSocket for CRDT sync
+ // Keyboard shortcuts
+ window.addEventListener('keydown', onKeyDown);
+
+ // Connect to WebSocket for sync
connectWebSocket();
// Start animation loop
@@ -135,36 +232,59 @@ function connectWebSocket() { }
function updateConnectionStatus(connected) {
- const status = document.getElementById('server-status');
- if (status) {
- status.textContent = connected ? 'Connected - Collaborative mode' : 'Disconnected - Reconnecting...';
- status.style.color = connected ? '#4ecdc4' : '#ff6b6b';
- }
+ 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':
- // Full graph sync - rebuild entire visualization
if (msg.graph) {
syncFullGraph(msg.graph);
}
break;
case 'addNode':
- // Another client added a node
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':
- // Another client added an edge
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;
}
}
@@ -172,14 +292,32 @@ function handleServerMessage(msg) { function syncFullGraph(serverGraph) {
console.log('Syncing full graph:', serverGraph.nodes?.length || 0, 'nodes,', serverGraph.edges?.length || 0, 'edges');
- // Clear current visualization
- nodeMeshes.forEach(mesh => scene.remove(mesh));
- edgeLines.forEach(line => scene.remove(line));
+ // 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 = [];
- selectedNode = null;
+ selectedNodes = [];
+ updateSelectionUI();
// Rebuild from server state
if (serverGraph.nodes) {
@@ -278,6 +416,36 @@ function addStarfield() { 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
@@ -286,19 +454,25 @@ function addNodeLocal(id, x, y, z) { // Create node data
graph.nodes.push({ id, x, y, z });
- // Create visual representation
- const geometry = new THREE.SphereGeometry(0.4, 32, 32);
+ // Create visual representation (shared geometry for performance)
const material = new THREE.MeshPhongMaterial({
color: colors.node,
shininess: 100
});
- const mesh = new THREE.Mesh(geometry, material);
+ 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);
@@ -340,6 +514,73 @@ function addEdgeLocal(fromId, toId) { 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({
@@ -355,6 +596,111 @@ function requestAddEdge(fromId, 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;
@@ -362,98 +708,146 @@ function addRandomNode() { 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);
- // Also request an edge to a random existing node
- if (graph.nodes.length > 0) {
- // We'll add the edge after the node is confirmed by server
- // For now, schedule it with a small delay
- const randomId = graph.nodes[Math.floor(Math.random() * graph.nodes.length)].id;
- setTimeout(() => {
- if (graph.nodes.length > 1) {
+ // 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;
- requestAddEdge(newNodeId, randomId);
+ if (newNodeId !== randomTarget) {
+ requestAddEdge(newNodeId, randomTarget);
+ }
+ } else if (Date.now() - startTime > 2000) {
+ clearInterval(checkInterval); // give up after 2s
}
- }, 100);
+ }, 50);
}
}
function addRandomEdge() {
- if (graph.nodes.length < 2) return;
-
- const fromNode = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
- let toNode = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
-
- // Make sure we don't connect a node to itself
- while (toNode.id === fromNode.id) {
- toNode = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
+ if (graph.nodes.length < 2) {
+ showToast('Need at least 2 nodes', 'info');
+ return;
}
- requestAddEdge(fromNode.id, toNode.id);
+ // 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 container = document.getElementById('visualizer-container');
- const width = container.clientWidth;
- const height = container.clientHeight;
+ const width = dom.container.clientWidth;
+ const height = dom.container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
-function onMouseMove(event) {
- const container = document.getElementById('visualizer-container');
- const rect = container.getBoundingClientRect();
+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;
- // Check for hover
+ 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 (mesh !== selectedNode) {
+ if (!selectedNodes.includes(mesh)) {
mesh.material.color.setHex(colors.node);
}
});
// Highlight hovered node
- if (intersects.length > 0 && intersects[0].object !== selectedNode) {
+ if (intersects.length > 0 && !selectedNodes.includes(intersects[0].object)) {
intersects[0].object.material.color.setHex(colors.nodeHover);
- container.style.cursor = 'pointer';
+ dom.container.style.cursor = 'pointer';
} else {
- container.style.cursor = 'grab';
+ dom.container.style.cursor = 'grab';
}
}
-function onMouseClick(event) {
- const container = document.getElementById('visualizer-container');
- const rect = container.getBoundingClientRect();
-
- mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
- mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
-
- raycaster.setFromCamera(mouse, camera);
- const intersects = raycaster.intersectObjects(nodeMeshes);
-
- // Deselect previous node
- if (selectedNode) {
- selectedNode.material.color.setHex(colors.node);
- }
+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));
+ }
- if (intersects.length > 0) {
- selectedNode = intersects[0].object;
- selectedNode.material.color.setHex(colors.nodeSelected);
- console.log('Selected node:', selectedNode.userData.nodeId);
- } else {
- selectedNode = null;
+ isDragging = false;
+ draggedNode = null;
+ controls.enabled = true;
+ dom.container.style.cursor = 'grab';
}
}
@@ -480,4 +874,4 @@ if (document.readyState === 'loading') { }
// Export for potential external use
-export { graph, requestAddNode, requestAddEdge, resetGraph };
+export { graph, requestAddNode, requestAddEdge, requestRemoveNode, requestRemoveEdge, resetGraph };
|
