@hive-p2p/server 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,734 @@
1
+ import { NetworkRendererElements, NetworkRendererOptions } from './renderer-options.mjs';
2
+ import { Node, NodesStore, ConnectionsStore } from './renderer-stores.mjs';
3
+ import { NODE, DISCOVERY } from '../core/config.mjs';
4
+
5
+ export class NetworkRenderer {
6
+ initCameraZ = 1400;
7
+ avoidAutoZoomUntil = 0;
8
+ lastAutoZoomDistance = 0;
9
+ fpsCountElement = document.getElementById('fpsCount');
10
+ maxVisibleConnections = 500; // to avoid performance issues
11
+ autoRotateEnabled = true;
12
+ autoRotateSpeed = .0005; // .001
13
+ autoRotateDelay = 3000; // delay before activating auto-rotation after mouse event
14
+ elements;
15
+ options;
16
+ onNodeLeftClick = null;
17
+ onNodeRightClick = null;
18
+ colors = {
19
+ background: 0x1a1a1a,
20
+ currentPeer: 0xFFD700,
21
+ hoveredPeer: 0xFF4500,
22
+ connectedPeerNeighbor: 0x4CAF50,
23
+ connectingPeerNeighbor: 0x03b5fc,
24
+ knownPeer: 0x7d7d7d,
25
+ publicNode: 0xffffff,
26
+ publicNodeBorder: 0xffffff,
27
+ twitchUser: 0xf216e4,
28
+ // CONNECTIONS
29
+ connection: 0x666666, // gray
30
+ currentPeerConnection: 0x4CAF50, // green
31
+ // DIRECT MESSAGES
32
+ traveledConnection: [
33
+ 0x2803fc, // blue
34
+ 0x0328fc, // light blue
35
+ 0x035afc // lighter blue
36
+ ],
37
+ toTravelConnection: 0x03b5fc, // even lighter blue for the remaining distance
38
+ // GOSSIP MESSAGES
39
+ gossipIncomingColor: 0xf542f5, // fuchsia
40
+ gossipOutgoingColor: 0xc24cc2, // dark fuchsia
41
+ };
42
+
43
+ // Internal state
44
+ scene = null;
45
+ camera = null;
46
+ renderer = null;
47
+ raycaster = new THREE.Raycaster();
48
+ mouse = new THREE.Vector2();
49
+
50
+ // Data structures
51
+ instancedMesh = null;
52
+ /** @type {NodesStore} */ nodesStore;
53
+ /** @type {ConnectionsStore} */ connectionsStore;
54
+
55
+ updateBatchMax = 500; // auto-adjusted based on fps
56
+ initUpdateBatchMax = 500;
57
+
58
+ // State
59
+ currentPeerId = null;
60
+ hoveredNodeId = null;
61
+ hoveredNodeRepaintInterval = null;
62
+ isAnimating = false;
63
+ isPhysicPaused = false;
64
+
65
+ /** This class is responsible for rendering the network visualization.
66
+ * @param {string} containerId
67
+ * @param {NetworkRendererOptions} options
68
+ * @param {NetworkRendererElements} rendererElements */
69
+ constructor(containerId, options, rendererElements) {
70
+ this.containerId = containerId;
71
+ this.elements = new NetworkRendererElements();
72
+ for (const key in rendererElements) if (key in this.elements) this.elements[key] = rendererElements[key];
73
+
74
+ this.options = new NetworkRendererOptions();
75
+ for (const key in options) if (key in this.options) this.options[key] = options[key];
76
+
77
+ this.init();
78
+ }
79
+
80
+ init() {
81
+ // Scene
82
+ this.scene = new THREE.Scene();
83
+ this.scene.background = new THREE.Color(this.colors.background);
84
+
85
+ // Stores (nodes + connections)
86
+ this.nodesStore = new NodesStore();
87
+ this.connectionsStore = new ConnectionsStore(this.nodesStore, this.scene);
88
+
89
+ // Camera
90
+ this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000);
91
+ this.camera.position.set(0, 0, this.initCameraZ);
92
+
93
+ // Renderer
94
+ const { antialias, precision } = this.options;
95
+ this.renderer = new THREE.WebGLRenderer({ antialias, precision });
96
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
97
+ const container = document.getElementById(this.containerId);
98
+ if (container) container.appendChild(this.renderer.domElement);
99
+ else document.body.appendChild(this.renderer.domElement);
100
+
101
+ this.#setupControls();
102
+
103
+ this.elements.modeSwitchBtn.textContent = this.options.mode === '2d' ? '2D' : '3D';
104
+ window.addEventListener('resize', () => {
105
+ this.camera.aspect = window.innerWidth / window.innerHeight;
106
+ this.camera.updateProjectionMatrix();
107
+ this.renderer.setSize(window.innerWidth, window.innerHeight);
108
+ });
109
+ this.renderer.domElement.addEventListener('mouseleave', () => {
110
+ if (this.hoveredNodeId) this.hoveredNodeId = null;
111
+ this.renderer.domElement.style.cursor = 'default';
112
+ this.#hideTooltip();
113
+ });
114
+
115
+ // PREPARE MESH INSTANCE
116
+ this.nodeCount = 0;
117
+ this.nodeIndexMap = {}; // id → instanceIndex
118
+ this.indexNodeMap = {}; // instanceIndex → id
119
+ this.nodeBorders = {}; // id → borderMesh
120
+
121
+ const geometry = new THREE.SphereGeometry(this.options.nodeRadius, 6, 3);
122
+ const material = new THREE.MeshBasicMaterial();
123
+ this.instancedMesh = new THREE.InstancedMesh(geometry, material, 50000);
124
+ this.instancedMesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(50000 * 3), 3);
125
+ this.instancedMesh.count = 0;
126
+ this.scene.add(this.instancedMesh);
127
+
128
+ if (this.isAnimating) return;
129
+ this.isAnimating = true;
130
+ this.#animate();
131
+ }
132
+
133
+ // Public API methods
134
+ #createMeshBorder = (nodeMesh, color = this.colors.publicNodeBorder) => {
135
+ const marginBetween = this.options.nodeBorderRadius * 2;
136
+ const borderGeometry = new THREE.RingGeometry(
137
+ this.options.nodeRadius + marginBetween,
138
+ this.options.nodeRadius + marginBetween + this.options.nodeBorderRadius,
139
+ 16
140
+ );
141
+ const borderMaterial = new THREE.MeshBasicMaterial({
142
+ color,
143
+ side: THREE.DoubleSide,
144
+ transparent: true,
145
+ opacity: .33
146
+ });
147
+ const borderMesh = new THREE.Mesh(borderGeometry, borderMaterial);
148
+ borderMesh.position.copy(nodeMesh.position);
149
+ borderMesh.lookAt(this.camera.position);
150
+ this.scene.add(borderMesh);
151
+ //nodeMesh.userData.border = borderMesh;
152
+ return borderMesh;
153
+ }
154
+ addOrUpdateNode(id, status = 'known', isPublic = false, neighbors = []) {
155
+ const existingNode = this.nodesStore.get(id);
156
+ if (!existingNode) { // Create new node
157
+ const newNode = new Node(id, status, isPublic, neighbors);
158
+ this.nodesStore.add(newNode);
159
+
160
+ // Get next available index for this node
161
+ const instanceIndex = this.nodeCount++; // Tu auras besoin d'un compteur this.nodeCount = 0
162
+ this.instancedMesh.count = this.nodeCount;
163
+ this.nodeIndexMap[id] = instanceIndex; // Map node id → instance index
164
+ this.indexNodeMap[instanceIndex] = id; // Map instance index → node id
165
+
166
+ // Set position in instanced mesh
167
+ const pos = newNode.position;
168
+ const matrix = new THREE.Matrix4();
169
+ matrix.setPosition(pos.x, pos.y, pos.z);
170
+ this.instancedMesh.setMatrixAt(instanceIndex, matrix);
171
+
172
+ // Set color
173
+ const color = new THREE.Color(this.#getNodeColor(id));
174
+ this.instancedMesh.setColorAt(instanceIndex, color);
175
+
176
+ // Handle borders (séparément, comme avant)
177
+ if (isPublic) this.nodeBorders[id] = this.#createMeshBorder({ position: pos }, this.colors.publicNodeBorder);
178
+ return;
179
+ }
180
+
181
+ // Update existing node
182
+ const instanceIndex = this.nodeIndexMap[id];
183
+ const newColor = new THREE.Color(this.#getNodeColor(id));
184
+ this.instancedMesh.setColorAt(instanceIndex, newColor);
185
+ this.instancedMesh.instanceColor.needsUpdate = true;
186
+
187
+ let needBorderUpdate = existingNode.isPublic !== isPublic;
188
+ existingNode.status = status;
189
+ existingNode.isPublic = isPublic;
190
+ existingNode.neighbors = neighbors;
191
+ this.instancedMesh.instanceMatrix.needsUpdate = true;
192
+ if (!needBorderUpdate) return;
193
+
194
+ // Handle border updates
195
+ const existingBorder = this.nodeBorders[id];
196
+ if (isPublic) {
197
+ if (existingBorder) this.scene.remove(existingBorder);
198
+ this.nodeBorders[id] = this.#createMeshBorder({ position: existingNode.position }, this.colors.publicNodeBorder);
199
+ return;
200
+ }
201
+
202
+ if (!existingBorder) return;
203
+ this.scene.remove(existingBorder);
204
+ delete this.nodeBorders[id];
205
+ }
206
+ removeNode(id) {
207
+ if (!this.nodesStore.has(id)) return; // Node doesn't exist
208
+
209
+ const instanceIndex = this.nodeIndexMap[id];
210
+ if (instanceIndex !== undefined) {
211
+ const lastIndex = this.nodeCount - 1;
212
+
213
+ if (instanceIndex !== lastIndex) {
214
+ // Récupérer l'ID du dernier nœud
215
+ const lastNodeId = this.indexNodeMap[lastIndex];
216
+ if (lastNodeId) {
217
+ // Copier les données du dernier nœud vers l'index à supprimer
218
+ const lastMatrix = new THREE.Matrix4();
219
+ const lastColor = new THREE.Color();
220
+
221
+ this.instancedMesh.getMatrixAt(lastIndex, lastMatrix);
222
+ this.instancedMesh.getColorAt(lastIndex, lastColor);
223
+
224
+ this.instancedMesh.setMatrixAt(instanceIndex, lastMatrix);
225
+ this.instancedMesh.setColorAt(instanceIndex, lastColor);
226
+ this.instancedMesh.instanceMatrix.needsUpdate = true;
227
+ this.instancedMesh.instanceColor.needsUpdate = true;
228
+
229
+ // Mettre à jour les mappings pour le nœud déplacé
230
+ this.nodeIndexMap[lastNodeId] = instanceIndex;
231
+ this.indexNodeMap[instanceIndex] = lastNodeId;
232
+ }
233
+ }
234
+
235
+ // Nettoyer les mappings pour le nœud supprimé
236
+ delete this.nodeIndexMap[id];
237
+ delete this.indexNodeMap[lastIndex];
238
+ this.nodeCount--;
239
+ this.instancedMesh.count = this.nodeCount;
240
+ }
241
+
242
+ // Gérer les borders
243
+ const border = this.nodeBorders[id];
244
+ if (border) {
245
+ this.scene.remove(border);
246
+ border.geometry.dispose();
247
+ border.material.dispose();
248
+ delete this.nodeBorders[id];
249
+ }
250
+ this.nodesStore.remove(id);
251
+ }
252
+ digestConnectionsArray(conns = [], displayNeighborsDegree = 1) {
253
+ const existingConnsKeys = {};
254
+ const drawLinesKeys = {};
255
+ const currentPeerNode = this.nodesStore.get(this.currentPeerId);
256
+ const cNeighbors = currentPeerNode?.neighbors || [];
257
+ for (const [fromId, toId] of conns) { // add new physicConnections
258
+ const { success, key } = this.connectionsStore.set(fromId, toId);
259
+ if (existingConnsKeys[key]) continue; // already processed
260
+ existingConnsKeys[key] = true; // store for control
261
+
262
+ const isOneOfThePeer = fromId === this.currentPeerId || toId === this.currentPeerId;
263
+ if (displayNeighborsDegree === 0 && !isOneOfThePeer) continue;
264
+
265
+ const [fromNode, toNode] = [this.nodesStore.get(fromId), this.nodesStore.get(toId)];
266
+ const [fNeighbors, tNeighbors] = [fromNode?.neighbors || [], toNode?.neighbors || []];
267
+ let isFirstDegree = cNeighbors.includes(fromId) || cNeighbors.includes(toId);
268
+ isFirstDegree = isFirstDegree || fNeighbors.includes(this.currentPeerId)
269
+ isFirstDegree = isFirstDegree || tNeighbors.includes(this.currentPeerId);
270
+ if (displayNeighborsDegree === 1 && !isFirstDegree) continue;
271
+ this.connectionsStore.updateOrAssignLineColor(fromId, toId);
272
+ drawLinesKeys[key] = true; // store for control
273
+ }
274
+
275
+ // remove physicConnections that are not in the array
276
+ // remove visual lines that are not in the array
277
+ for (const connStr in this.connectionsStore.store) {
278
+ const peerConn = this.connectionsStore.store[connStr];
279
+ if (peerConn.isHovered) continue; // still hovered
280
+ if (peerConn.repaintIgnored) continue; // still ignored
281
+ if (!existingConnsKeys[connStr]) this.connectionsStore.unset(...connStr.split(':'));
282
+ else if (!drawLinesKeys[connStr] && peerConn.line) this.connectionsStore.unassignLine(...connStr.split(':'));
283
+ }
284
+ }
285
+ displayDirectMessageRoute(relayerId, route = [], frameToIgnore = 30) {
286
+ const fto = Math.round(frameToIgnore * (this.frameCount / 60));
287
+ const maxTraveledColorIndex = this.colors.traveledConnection.length - 1;
288
+ let traveledIndex = 0;
289
+ let isRelayerIdPassed = false;
290
+ for (let i = 1; i < route.length; i++) {
291
+ const color = isRelayerIdPassed ? this.colors.toTravelConnection : this.colors.traveledConnection[traveledIndex];
292
+ this.connectionsStore.updateOrAssignLineColor(route[i - 1], route[i], color, .5, fto, true);
293
+ traveledIndex = Math.min(traveledIndex + 1, maxTraveledColorIndex);
294
+ if (route[i - 1] === relayerId) isRelayerIdPassed = true;
295
+ }
296
+ }
297
+ displayGossipMessageRoute(relayerId, senderId, topic = 'peer_connected', data, frameToIgnore = 25) {
298
+ const fto = Math.round(frameToIgnore * (this.frameCount / 60));
299
+ const fto2 = Math.round((frameToIgnore + 5) * (this.frameCount / 60));
300
+ this.connectionsStore.updateOrAssignLineColor(senderId, relayerId, this.colors.gossipOutgoingColor, .4, fto, true);
301
+ this.connectionsStore.updateOrAssignLineColor(relayerId, this.currentPeerId, this.colors.gossipIncomingColor, .8, fto2, true);
302
+ }
303
+ setCurrentPeer(peerId, clearNetworkOneChange = true) {
304
+ if (clearNetworkOneChange && peerId !== this.currentPeerId) {
305
+ this.clearNetwork();
306
+ this.avoidAutoZoomUntil = 0;
307
+ }
308
+
309
+ // Reset previous current peer
310
+ if (this.currentPeerId && this.nodesStore.has(this.currentPeerId)) this.nodesStore.get(this.currentPeerId).status = 'known';
311
+ if (peerId && this.nodesStore.has(peerId)) this.nodesStore.get(peerId).status = 'current';
312
+ this.currentPeerId = peerId;
313
+ }
314
+ updateStats() {
315
+ const info = this.nodesStore.getInfo();
316
+ this.maxDistance = info.maxDistance;
317
+ this.elements.nodeCountElement.textContent = info.total;
318
+ const { connsCount, linesCount } = this.connectionsStore.getConnectionsCount();
319
+ this.elements.connectionsCountElement.textContent = connsCount;
320
+ this.elements.linesCountElement.textContent = linesCount;
321
+ this.elements.connectingCountElement.textContent = info.connecting;
322
+ const nonPublicNeighbors = info.connected - info.connectedPublic;
323
+ const isTargetReached = nonPublicNeighbors >= DISCOVERY.TARGET_NEIGHBORS_COUNT;
324
+ this.elements.neighborCountElement.textContent = `${nonPublicNeighbors} | +${info.connectedPublic} Public ${isTargetReached ? '🟢' : ''}`;
325
+ this.elements.publicNeighborCountElement.textContent = info.connectedPublic;
326
+ }
327
+ switchMode() {
328
+ this.options.mode = this.options.mode === '2d' ? '3d' : '2d';
329
+ // reset camera angle
330
+ this.camera.position.set(0, 0, 500);
331
+ this.camera.lookAt(0, 0, 0);
332
+ this.elements.modeSwitchBtn.textContent = this.options.mode === '2d' ? '2D' : '3D';
333
+ }
334
+ clearNetwork() {
335
+ // Clear data
336
+ this.nodesStore = new NodesStore(this.nodes);
337
+ this.connectionsStore.destroy();
338
+ this.connectionsStore = new ConnectionsStore(this.nodesStore, this.scene);
339
+ this.currentPeerId = null;
340
+ this.hoveredNodeId = null;
341
+
342
+ // Clear InstancedMesh nodes - Reset count to 0
343
+ this.nodeCount = 0;
344
+ this.instancedMesh.count = 0;
345
+ this.nodeIndexMap = {};
346
+ this.indexNodeMap = {};
347
+
348
+ // Clear borders
349
+ for (const id in this.nodeBorders) {
350
+ const border = this.nodeBorders[id];
351
+ this.scene.remove(border);
352
+ border.geometry.dispose();
353
+ border.material.dispose();
354
+ }
355
+ this.nodeBorders = {};
356
+
357
+ // reset camera
358
+ this.camera.position.set(0, 0, this.initCameraZ);
359
+ this.camera.lookAt(0, 0, 0);
360
+ this.updateBatchMax = this.initUpdateBatchMax; // reset rendering batch size
361
+ }
362
+ destroy() {
363
+ this.isAnimating = false;
364
+ this.scene.clear();
365
+ this.renderer.dispose();
366
+ if (this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
367
+ }
368
+
369
+ // Internal methods
370
+ /** @param {string} axis @param {'3d'|'2d'|null} restrictToMode */
371
+ #autoRotate(axis = 'z', restrictToMode = null) {
372
+ if (!this.autoRotateEnabled || !this.isAnimating) return;
373
+ try { if (!restrictToMode || this.options.mode === restrictToMode) this.scene.rotation[axis] -= this.autoRotateSpeed;
374
+ } catch (error) { console.error('Error during auto-rotation:', error); }
375
+ }
376
+ #autoZoom(margin = 1.2) {
377
+ if (!this.isAnimating || this.options.mode !== '3d' || this.isPhysicPaused) return;
378
+ if (this.avoidAutoZoomUntil > Date.now()) return;
379
+
380
+ const maxDist = this.maxDistance * margin;
381
+ const fov = this.camera.fov * (Math.PI / 180);
382
+ const height = 2 * maxDist;
383
+ const distance = height / (2 * Math.tan(fov / 2));
384
+ const targetDistance = Math.max(distance * 1.2, this.initCameraZ);
385
+ const currentDistance = this.camera.position.length();
386
+ const zoomSpeed = .1;
387
+ const mode = currentDistance < targetDistance ? true : false; // true = zoom out | false = zoom in
388
+ const delta = Math.min(20, Math.abs(targetDistance - currentDistance) * zoomSpeed);
389
+ const newDistance = mode ? currentDistance + delta : currentDistance - delta;
390
+
391
+ if (Math.abs(newDistance - this.lastAutoZoomDistance) < 1) return;
392
+ this.lastAutoZoomDistance = newDistance;
393
+
394
+ this.camera.position.normalize().multiplyScalar(newDistance);
395
+ this.camera.lookAt(0, 0, 0);
396
+ }
397
+ #setupControls() {
398
+ let setupAutoRotateTimeout;
399
+ const initZoomSpeed2D = .1;
400
+ const maxZoomSpeed2D = 5;
401
+ let zoomSpeed2D = .1;
402
+ let zoomSpeedIncrement = .02;
403
+ let zoomSpeedIncrementFactor = .01;
404
+ let lastZoomDirection = null;
405
+ let isMouseDown = false;
406
+ let mouseDownGrabCursorTimeout = null;
407
+ let mouseButton = null;
408
+ let previousMousePosition = { x: 0, y: 0 };
409
+
410
+ const domElement = this.renderer.domElement;
411
+ domElement.addEventListener('mousedown', (e) => {
412
+ if (setupAutoRotateTimeout) clearTimeout(setupAutoRotateTimeout);
413
+ this.autoRotateEnabled = false;
414
+ setupAutoRotateTimeout = setTimeout(() => this.autoRotateEnabled = true, this.autoRotateDelay);
415
+
416
+ isMouseDown = true;
417
+ mouseButton = e.button;
418
+ previousMousePosition.x = e.clientX;
419
+ previousMousePosition.y = e.clientY;
420
+ if (mouseDownGrabCursorTimeout) clearTimeout(mouseDownGrabCursorTimeout);
421
+ mouseDownGrabCursorTimeout = setTimeout(() => domElement.style.cursor = 'grabbing', 200);
422
+ });
423
+
424
+ domElement.addEventListener('mouseup', () => {
425
+ isMouseDown = false;
426
+ mouseButton = null;
427
+ zoomSpeed2D = initZoomSpeed2D;
428
+ lastZoomDirection = null;
429
+ if (mouseDownGrabCursorTimeout) clearTimeout(mouseDownGrabCursorTimeout);
430
+ setTimeout(() => domElement.style.cursor = 'default', 20);
431
+ });
432
+
433
+ domElement.addEventListener('mousemove', (e) => {
434
+ if (isMouseDown) {
435
+ const deltaX = e.clientX - previousMousePosition.x;
436
+ const deltaY = e.clientY - previousMousePosition.y;
437
+ const mouseDirection = deltaY > 0 ? 'down' : 'up';
438
+ //console.log(`Mouse moved: ${mouseDirection} (${deltaX}, ${deltaY})`);
439
+
440
+ if (mouseButton === 2 && this.options.mode === '3d') { // Right mouse 3D - rotate
441
+ const rotationSpeed = 0.005;
442
+ const spherical = new THREE.Spherical();
443
+ spherical.setFromVector3(this.camera.position);
444
+ spherical.theta -= deltaX * rotationSpeed;
445
+ spherical.phi += deltaY * rotationSpeed;
446
+ spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
447
+ this.camera.position.setFromSpherical(spherical);
448
+ this.camera.lookAt(0, 0, 0);
449
+ } else if (mouseDirection && mouseButton === 2 && this.options.mode === '2d') { // Right mouse 2D - Zoom
450
+ // log increase zoom speed on same direction, until max
451
+ const oppositeDirection = !lastZoomDirection
452
+ || lastZoomDirection === 'out' && mouseDirection === 'down'
453
+ || lastZoomDirection === 'in' && mouseDirection === 'up';
454
+
455
+ if (oppositeDirection && zoomSpeed2D === initZoomSpeed2D)
456
+ lastZoomDirection = mouseDirection === 'up' ? 'out' : 'in'; // handle direction switch
457
+
458
+ const zf = zoomSpeed2D * zoomSpeedIncrementFactor;
459
+ const upperSpeed = zoomSpeed2D + zf + zoomSpeedIncrement;
460
+ const lowerSpeed = zoomSpeed2D - zf - zoomSpeedIncrement;
461
+ if (lastZoomDirection === 'out')
462
+ if (mouseDirection === 'down') zoomSpeed2D = Math.max(0.1, lowerSpeed);
463
+ else zoomSpeed2D = Math.min(maxZoomSpeed2D, upperSpeed);
464
+ else if (lastZoomDirection === 'in')
465
+ if (mouseDirection === 'up') zoomSpeed2D = Math.max(0.1, lowerSpeed);
466
+ else zoomSpeed2D = Math.min(maxZoomSpeed2D, upperSpeed);
467
+
468
+ //console.log(`Zoom speed: ${zoomSpeed2D.toFixed(2)}`);
469
+
470
+ const forward = new THREE.Vector3();
471
+ this.camera.getWorldDirection(forward);
472
+ this.camera.position.add(forward.multiplyScalar(deltaY * zoomSpeed2D));
473
+ } else if (mouseButton === 0) { // Left mouse - pan
474
+ const panSpeed = 1;
475
+ const right = new THREE.Vector3();
476
+ const up = new THREE.Vector3();
477
+ this.camera.getWorldDirection(right);
478
+ right.cross(this.camera.up).normalize();
479
+ up.copy(this.camera.up);
480
+
481
+ const panVector = right.multiplyScalar(-deltaX * panSpeed)
482
+ .add(up.multiplyScalar(deltaY * panSpeed));
483
+ this.camera.position.add(panVector);
484
+ }
485
+
486
+ previousMousePosition.x = e.clientX;
487
+ previousMousePosition.y = e.clientY;
488
+ this.avoidAutoZoomUntil = Date.now() + 5000;
489
+ }
490
+
491
+ this.#handleMouseMove(e);
492
+ });
493
+
494
+ domElement.addEventListener('wheel', (e) => {
495
+ e.preventDefault();
496
+ const zoomSpeed = .4;
497
+ const forward = new THREE.Vector3();
498
+ this.camera.getWorldDirection(forward);
499
+ this.camera.position.add(forward.multiplyScalar(e.deltaY * -zoomSpeed));
500
+ this.avoidAutoZoomUntil = Date.now() + 5000;
501
+ });
502
+
503
+ this.elements.modeSwitchBtn.addEventListener('click', () => this.switchMode());
504
+ domElement.addEventListener('click', () => this.onNodeLeftClick?.(this.hoveredNodeId));
505
+ domElement.addEventListener('contextmenu', (e) => {
506
+ e.preventDefault();
507
+ if (domElement.style.cursor === 'grabbing') return;
508
+ this.onNodeRightClick?.(this.hoveredNodeId)
509
+ });
510
+ window.addEventListener('keydown', (e) => {
511
+ if (e.code === 'Space') this.isPhysicPaused = !this.isPhysicPaused;
512
+ });
513
+ }
514
+ #handleMouseMove(event) {
515
+ if (this.renderer.domElement.style.cursor === 'grabbing') return;
516
+ this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
517
+ this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
518
+
519
+ this.raycaster.setFromCamera(this.mouse, this.camera);
520
+ const intersects = this.raycaster.intersectObjects(this.scene.children);
521
+
522
+ let foundNode = null;
523
+ for (const intersect of intersects) {
524
+ if (intersect.object !== this.instancedMesh || intersect.instanceId === undefined) continue;
525
+ foundNode = this.indexNodeMap[intersect.instanceId];
526
+ if (foundNode) break;
527
+ }
528
+
529
+ if (foundNode === this.hoveredNodeId) return; // No change in hovered node
530
+
531
+ // Reset previous hovered node
532
+ if (this.hoveredNodeId) {
533
+ clearInterval(this.hoveredNodeRepaintInterval);
534
+ this.hoveredNodeRepaintInterval = null;
535
+ const prevInstanceIndex = this.nodeIndexMap[this.hoveredNodeId];
536
+ if (prevInstanceIndex !== undefined) {
537
+ const originalColor = new THREE.Color(this.#getNodeColor(this.hoveredNodeId));
538
+ this.instancedMesh.setColorAt(prevInstanceIndex, originalColor);
539
+ this.instancedMesh.instanceColor.needsUpdate = true;
540
+ }
541
+ }
542
+
543
+ this.hoveredNodeId = foundNode;
544
+ this.#updateHoveredNodeInfo(event.clientX, event.clientY);
545
+ }
546
+ #updateHoveredNodeInfo(clientX, clientY) {
547
+ if (!this.hoveredNodeId) {
548
+ this.renderer.domElement.style.cursor = 'default';
549
+ this.#hideTooltip();
550
+ this.connectionsStore.resetHovered();
551
+ return;
552
+ }
553
+
554
+ this.#showTooltip(clientX, clientY, this.hoveredNodeId);
555
+ if (this.hoveredNodeId === this.currentPeerId) return;
556
+
557
+ // Set hover color
558
+ const instanceIndex = this.nodeIndexMap[this.hoveredNodeId];
559
+ if (instanceIndex === undefined) return;
560
+
561
+ const hoverColor = new THREE.Color(this.colors.hoveredPeer);
562
+ this.instancedMesh.setColorAt(instanceIndex, hoverColor);
563
+ this.instancedMesh.instanceColor.needsUpdate = true;
564
+ this.renderer.domElement.style.cursor = 'pointer';
565
+
566
+ // Set hovered connections flag
567
+ this.hoveredNodeRepaintInterval = setInterval(() => {
568
+ const hoveredNode = this.nodesStore.get(this.hoveredNodeId);
569
+ console.log(`Repainting hovered node ${this.hoveredNodeId} and its neighbors`);
570
+ const neighbors = hoveredNode ? hoveredNode.neighbors : [];
571
+ for (const toId of neighbors) this.connectionsStore.setHovered(toId, this.hoveredNodeId);
572
+ }, 60);
573
+ }
574
+ #showTooltip(x, y, nodeId, element = document.getElementById('tooltip')) {
575
+ const node = this.nodesStore.get(nodeId);
576
+ if (!node) return;
577
+
578
+ const json = {
579
+ Peer: nodeId,
580
+ Type: node.status,
581
+ NeighborsCount: node.neighbors.length,
582
+ Neighbors: node.neighbors.length > 0 ? node.neighbors : 'None',
583
+ IsPublic: node.isPublic
584
+ };
585
+
586
+ element.innerHTML = `<pre>${JSON.stringify(json, null, 2)}</pre>`;
587
+ element.style.left = x + 10 + 'px';
588
+ element.style.top = y + 10 + 'px';
589
+ element.style.display = 'block';
590
+ }
591
+ #hideTooltip(element = document.getElementById('tooltip')) {
592
+ element.style.display = 'none';
593
+ }
594
+ #getNodeColor(peerId) {
595
+ const { status, isPublic } = this.nodesStore.get(peerId);
596
+ const isTwitchUser = peerId.startsWith('F_');
597
+ if (status !== 'current' && isTwitchUser) return this.colors.twitchUser;
598
+ switch (status) {
599
+ case 'current': return this.colors.currentPeer;
600
+ case 'connected': return this.colors.connectedPeerNeighbor;
601
+ case 'connecting': return this.colors.connectingPeerNeighbor;
602
+ default: return isPublic ? this.colors.publicNode : this.colors.knownPeer;
603
+ }
604
+ }
605
+ #getReducedBatch = (nodeIds) => {
606
+ const nodeIdsCount = nodeIds.length;
607
+ const batchSize = Math.floor(Math.min(nodeIdsCount, this.updateBatchMax));
608
+ if (batchSize >= nodeIdsCount) return { batchIds: nodeIds, forceMultiplier: 1 };
609
+
610
+ const result = [];
611
+ for (let i = 0; i < batchSize; i++)
612
+ result.push(nodeIds[i + Math.floor(Math.random() * (nodeIdsCount - i))]);
613
+
614
+ const batchIds = result.slice(0, batchSize);
615
+ const forceMultiplier = Math.round(Math.max(1, nodeIds.length / batchSize));
616
+ return { batchIds, forceMultiplier };
617
+ }
618
+ #updateNodesPositions(nodeIds = [], lockCurrentNodePosition = true) {
619
+ const { batchIds, forceMultiplier } = this.#getReducedBatch(nodeIds);
620
+ for (const id of batchIds) {
621
+ const [pos, vel] = [this.nodesStore.get(id)?.position, this.nodesStore.get(id)?.velocity];
622
+ const node = this.nodesStore.get(id);
623
+ const instanceIndex = this.nodeIndexMap[id];
624
+ if (!pos || !vel || !node || instanceIndex === undefined) continue;
625
+
626
+ let fx = 0, fy = 0, fz = 0;
627
+
628
+ // Repulsion between nodes
629
+ for (const otherId of [...batchIds, ...node.neighbors]) {
630
+ if (id === otherId) continue;
631
+
632
+ const otherNode = this.nodesStore.get(otherId);
633
+ const otherPos = otherNode?.position;
634
+ if (!otherPos || !otherNode) continue;
635
+
636
+ const dx = pos.x - otherPos.x;
637
+ const dy = pos.y - otherPos.y;
638
+ const dz = pos.z - otherPos.z;
639
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
640
+ if (distance > this.options.repulsionOpts.maxDistance) continue;
641
+
642
+ const force = this.options.repulsion * (distance * distance + 1); // +1 to avoid division by zero
643
+ fx += (dx / distance) * force;
644
+ fy += (dy / distance) * force;
645
+ fz += (dz / distance) * force;
646
+ }
647
+
648
+ // Attraction along physicConnections
649
+ for (const neighborId of node.neighbors) {
650
+ const neighborPos = this.nodesStore.get(neighborId)?.position;
651
+ if (!neighborPos) continue;
652
+
653
+ const dx = neighborPos.x - pos.x;
654
+ const dy = neighborPos.y - pos.y;
655
+ const dz = neighborPos.z - pos.z;
656
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
657
+ if (distance < this.options.attractionOpts.minDistance) continue;
658
+
659
+ const force = distance * this.options.attraction;
660
+ fx += (dx / distance) * force;
661
+ fy += (dy / distance) * force;
662
+ fz += (dz / distance) * force;
663
+ }
664
+
665
+ // Center force
666
+ fx += -pos.x * this.options.centerForce;
667
+ fy += -pos.y * this.options.centerForce;
668
+ fz += -pos.z * this.options.centerForce;
669
+
670
+ // Update velocity
671
+ vel.x = (vel.x + fx) * this.options.damping;
672
+ vel.y = (vel.y + fy) * this.options.damping;
673
+ vel.z = (vel.z + fz) * this.options.damping;
674
+
675
+ // Limit velocity
676
+ const speed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z);
677
+ if (speed > this.options.maxVelocity) {
678
+ vel.x = (vel.x / speed) * this.options.maxVelocity * forceMultiplier;
679
+ vel.y = (vel.y / speed) * this.options.maxVelocity * forceMultiplier;
680
+ vel.z = (vel.z / speed) * this.options.maxVelocity * forceMultiplier;
681
+ }
682
+
683
+ // Update position
684
+ pos.x += vel.x;
685
+ pos.y += vel.y;
686
+ pos.z += vel.z;
687
+ if (this.currentPeerId === id && lockCurrentNodePosition) for (const key of ['x', 'y', 'z']) pos[key] = 0;
688
+
689
+ // Update visual object
690
+ const matrix = new THREE.Matrix4();
691
+ const visualZ = this.options.mode === '3d' ? pos.z : 0;
692
+ matrix.setPosition(pos.x, pos.y, visualZ);
693
+ this.instancedMesh.setMatrixAt(instanceIndex, matrix);
694
+
695
+ // Update border position
696
+ const border = this.nodeBorders[id];
697
+ if (!border) continue;
698
+ border.position.set(pos.x, pos.y, visualZ);
699
+ border.lookAt(this.camera.position);
700
+ }
701
+ }
702
+
703
+ lastPhysicUpdate = 0;
704
+ frameCount = 60;
705
+ lastFpsUpdate = 0;
706
+ #animate() {
707
+ if (!this.isAnimating) return;
708
+
709
+ const currentTime = performance.now();
710
+ this.frameCount++;
711
+ if (currentTime - this.lastFpsUpdate >= 1000) {
712
+ if (this.frameCount < 60 * .98) this.updateBatchMax = Math.round(Math.max(100, this.updateBatchMax * .9));
713
+ this.fpsCountElement.textContent = this.frameCount;
714
+ this.frameCount = 0;
715
+ this.lastFpsUpdate = currentTime;
716
+ }
717
+
718
+ const shouldUpdatePhysic = currentTime - this.lastPhysicUpdate >= 1000 / 60;
719
+ if (shouldUpdatePhysic && !this.isPhysicPaused) {
720
+ this.lastPhysicUpdate = currentTime;
721
+ const nodeIds = this.nodesStore.getNodesIds();
722
+ this.#updateNodesPositions(nodeIds);
723
+ this.#autoRotate();
724
+ this.#autoZoom();
725
+ }
726
+
727
+ this.connectionsStore.updateConnections(this.currentPeerId, this.hoveredNodeId, this.colors, this.options.mode);
728
+ this.instancedMesh.instanceMatrix.needsUpdate = true;
729
+ this.instancedMesh.instanceColor.needsUpdate = true;
730
+ this.renderer.render(this.scene, this.camera);
731
+
732
+ requestAnimationFrame(() => this.#animate());
733
+ }
734
+ }