noteconnection 1.1.2 → 1.3.0

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,685 @@
1
+ /**
2
+ * Path Mode Application Controller
3
+ * Handles interaction, rendering, and worker communication.
4
+ */
5
+
6
+ window.pathApp = {
7
+ canvas: null,
8
+ ctx: null,
9
+ worker: null,
10
+ transform: { k: 1, x: 0, y: 0 },
11
+ nodes: [],
12
+ links: [],
13
+ width: 0,
14
+ height: 0,
15
+
16
+ // State
17
+ centralNodeId: null,
18
+ learningHistory: [],
19
+ completedNodes: new Set(),
20
+ currentTargetId: null,
21
+
22
+ // Animation State
23
+ animationId: null,
24
+ orbitalAngle: 0,
25
+
26
+ init: function(startNodeId) {
27
+ console.log('Path Mode Initializing...');
28
+ this.setupCanvas();
29
+ this.setupWorker();
30
+ this.setupWebSocket(); // Connect to Bridge
31
+ this.setupUI();
32
+
33
+ // Initialize Reader if available and not already set
34
+ if (typeof Reader !== 'undefined' && !window.reader) {
35
+ window.reader = new Reader();
36
+ console.log('Reader initialized');
37
+ } else if (window.reader) {
38
+ console.log('Reader already active');
39
+ }
40
+
41
+ this.loadHistory(); // Load from localStorage
42
+
43
+ // Start Loop
44
+ this.animate();
45
+
46
+ // Load data logic
47
+ if (typeof graphData !== 'undefined') {
48
+ this.startProcessing(startNodeId);
49
+ } else if (typeof window.graphData !== 'undefined') {
50
+ this.startProcessing(startNodeId);
51
+ } else {
52
+ console.warn('Data loading logic needed for standalone mode');
53
+ }
54
+ },
55
+
56
+ setupWebSocket: function() {
57
+ this.ws = new WebSocket('ws://localhost:9876');
58
+ this.ws.onopen = () => console.log('[PathApp] Connected to Bridge');
59
+ this.ws.onmessage = (e) => {
60
+ try {
61
+ const msg = JSON.parse(e.data);
62
+ if (msg.type === 'nodeClick') {
63
+ console.log('[PathApp] Received remote click:', msg.payload);
64
+ this.switchCentral(msg.payload);
65
+ }
66
+ } catch(err) {
67
+ console.error('WS Error', err);
68
+ }
69
+ };
70
+ },
71
+
72
+ setupCanvas: function() {
73
+ this.canvas = document.getElementById('path-canvas');
74
+ this.width = window.innerWidth;
75
+ this.height = window.innerHeight;
76
+ this.canvas.width = this.width;
77
+ this.canvas.height = this.height;
78
+ this.ctx = this.canvas.getContext('2d', { alpha: false });
79
+
80
+ window.addEventListener('resize', () => {
81
+ this.width = window.innerWidth;
82
+ this.height = window.innerHeight;
83
+ this.canvas.width = this.width;
84
+ this.canvas.height = this.height;
85
+ this.render();
86
+ });
87
+
88
+ const zoom = d3.zoom()
89
+ .scaleExtent([0.1, 5])
90
+ .on('zoom', (e) => {
91
+ this.transform = e.transform;
92
+ // Render handled by loop
93
+ })
94
+ .filter(event => !event.type.includes('dblclick'));
95
+
96
+ d3.select(this.canvas).call(zoom).on("dblclick.zoom", null);
97
+ this.canvas.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
98
+ },
99
+
100
+ setupWorker: function() {
101
+ this.worker = new Worker('path_worker.js');
102
+ this.worker.onmessage = (e) => {
103
+ const { type, payload } = e.data;
104
+ switch(type) {
105
+ case 'pathResult':
106
+ this.handlePathResult(payload);
107
+ break;
108
+ case 'layoutTick':
109
+ break;
110
+ case 'log':
111
+ console.log('[PathWorker]', payload);
112
+ break;
113
+ }
114
+ };
115
+ },
116
+
117
+ setupUI: function() {
118
+ document.getElementById('btn-exit-path').addEventListener('click', () => {
119
+ document.getElementById('path-container').style.display = 'none';
120
+ document.getElementById('graph-wrapper').style.display = 'block';
121
+ window.dispatchEvent(new Event('resize'));
122
+ });
123
+
124
+ document.getElementById('learning-mode').addEventListener('change', (e) => {
125
+ const mode = e.target.value;
126
+ if (mode === 'diffusion') {
127
+ this.showNodeSelector();
128
+ } else {
129
+ this.currentTargetId = null; // Clear target for Domain Mode
130
+ this.updateTargetDisplay();
131
+ this.triggerUpdate();
132
+ }
133
+ });
134
+ document.getElementById('strategy').addEventListener('change', () => this.triggerUpdate());
135
+ document.getElementById('layout-style').addEventListener('change', () => this.triggerUpdate());
136
+
137
+ document.getElementById('btn-mark-complete').addEventListener('click', () => this.markComplete());
138
+
139
+ document.getElementById('btn-toggle-history').addEventListener('click', () => {
140
+ const sidebar = document.getElementById('learning-history-sidebar');
141
+ sidebar.style.zIndex = '3000'; // Correct Z-Index
142
+ if (sidebar.style.display === 'none' || sidebar.style.display === '') {
143
+ sidebar.style.display = 'flex';
144
+ // Trigger reflow
145
+ sidebar.offsetHeight;
146
+ setTimeout(() => sidebar.style.transform = 'translateX(0)', 10);
147
+ } else {
148
+ sidebar.style.transform = 'translateX(100%)';
149
+ setTimeout(() => sidebar.style.display = 'none', 300);
150
+ }
151
+ });
152
+
153
+ document.getElementById('btn-close-history').addEventListener('click', () => {
154
+ const sidebar = document.getElementById('learning-history-sidebar');
155
+ sidebar.style.transform = 'translateX(100%)';
156
+ setTimeout(() => sidebar.style.display = 'none', 300);
157
+ });
158
+
159
+ // Add Target Display UI if missing
160
+ if (!document.getElementById('target-display')) {
161
+ const toolbar = document.getElementById('path-toolbar');
162
+ const targetDiv = document.createElement('div');
163
+ targetDiv.id = 'target-display';
164
+ targetDiv.className = 'toolbar-group';
165
+ targetDiv.style.display = 'none';
166
+ targetDiv.innerHTML = `
167
+ <span id="target-label" style="font-size: 0.8rem; color: #aaa; margin-right: 5px;"></span>
168
+ <button id="btn-change-target" class="btn-small">Change</button>
169
+ `;
170
+ // Insert after strategy
171
+ toolbar.insertBefore(targetDiv, document.getElementById('learning-mode').parentNode.nextSibling);
172
+
173
+ document.getElementById('btn-change-target').addEventListener('click', () => {
174
+ this.showNodeSelector();
175
+ });
176
+ }
177
+
178
+ document.getElementById('node-select-input').addEventListener('input', (e) => this.filterNodeList(e.target.value));
179
+ document.getElementById('btn-close-node-select').addEventListener('click', () => {
180
+ document.getElementById('node-select-modal').style.display = 'none';
181
+ // Revert if no target selected?
182
+ if (!this.currentTargetId && document.getElementById('learning-mode').value === 'diffusion') {
183
+ // Keep as is or switch back?
184
+ }
185
+ });
186
+ },
187
+
188
+ updateTargetDisplay: function() {
189
+ const div = document.getElementById('target-display');
190
+ const mode = document.getElementById('learning-mode').value;
191
+
192
+ if (mode === 'diffusion' && this.currentTargetId) {
193
+ const sourceData = (typeof graphData !== 'undefined') ? graphData : window.graphData;
194
+ const node = sourceData.nodes.find(n => n.id === this.currentTargetId);
195
+ const label = node ? node.label : this.currentTargetId;
196
+
197
+ document.getElementById('target-label').innerText = `Target: ${label}`;
198
+ div.style.display = 'flex';
199
+ div.style.alignItems = 'center';
200
+ } else {
201
+ div.style.display = 'none';
202
+ }
203
+ },
204
+
205
+ loadHistory: function() {
206
+ const retain = document.getElementById('set-retain-history')?.checked ?? true;
207
+ if (!retain) return;
208
+ const stored = localStorage.getItem('nc_path_history');
209
+ if (stored) {
210
+ try {
211
+ this.learningHistory = JSON.parse(stored);
212
+ // Validate IDs
213
+ const validHistory = [];
214
+ this.learningHistory.forEach(n => {
215
+ if (n && n.id) {
216
+ this.completedNodes.add(n.id);
217
+ validHistory.push(n);
218
+ }
219
+ });
220
+ this.learningHistory = validHistory;
221
+ this.updateHistorySidebar();
222
+ } catch(e) { console.error(e); }
223
+ }
224
+ },
225
+ saveHistory: function() {
226
+ if (document.getElementById('set-retain-history')?.checked ?? true) {
227
+ localStorage.setItem('nc_path_history', JSON.stringify(this.learningHistory));
228
+ }
229
+ },
230
+
231
+ triggerUpdate: function() {
232
+ const mode = document.getElementById('learning-mode').value;
233
+ const strategy = document.getElementById('strategy').value;
234
+ const layout = document.getElementById('layout-style').value;
235
+
236
+ // Preserve central focus if we already have one
237
+ if (layout === 'orbital' && !this.centralNodeId && this.nodes.length > 0) {
238
+ const next = this.nodes.find(n => !this.completedNodes.has(n.id));
239
+ this.centralNodeId = next ? next.id : this.nodes[0].id;
240
+ }
241
+
242
+ this.worker.postMessage({
243
+ type: 'computePath',
244
+ payload: { mode, strategy, layout, targetId: this.currentTargetId }
245
+ });
246
+
247
+ this.updateTargetDisplay();
248
+ },
249
+
250
+ startProcessing: function(targetId) {
251
+ this.currentTargetId = targetId;
252
+ const sourceData = (typeof graphData !== 'undefined') ? graphData : window.graphData;
253
+ const nodes = sourceData.nodes.map(n => ({
254
+ id: n.id, label: n.label, inDegree: n.inDegree, outDegree: n.outDegree, centrality: n.centrality
255
+ }));
256
+ // D3 mutates links to objects, we need IDs for the worker
257
+ const links = sourceData.edges.map(l => ({
258
+ source: typeof l.source === 'object' ? l.source.id : l.source,
259
+ target: typeof l.target === 'object' ? l.target.id : l.target,
260
+ type: l.type,
261
+ weight: l.weight
262
+ }));
263
+
264
+ this.worker.postMessage({ type: 'initData', payload: { nodes, links } });
265
+ this.triggerUpdate();
266
+ },
267
+
268
+ handlePathResult: function(result) {
269
+ this.nodes = result.nodes;
270
+ this.links = result.edges;
271
+
272
+ document.getElementById('path-count').innerText = this.nodes.length;
273
+
274
+ // Auto-set central if needed
275
+ if (this.nodes.length > 0) {
276
+ const exists = this.nodes.find(n => n.id === this.centralNodeId);
277
+ if (!this.centralNodeId || !exists) {
278
+ const cand = this.nodes.find(n => !this.completedNodes.has(n.id)) || this.nodes[0];
279
+ this.centralNodeId = cand.id;
280
+ }
281
+ }
282
+
283
+ this.nodes.forEach(n => {
284
+ if (this.completedNodes.has(n.id)) n.isCompleted = true;
285
+ // Initialize orbital params if needed - randomized for "Cloud" effect
286
+ if (!n.orbitalSpeed) n.orbitalSpeed = (Math.random() - 0.5) * 0.0015; // Slow down slightly
287
+ if (!n.orbitalPhase) n.orbitalPhase = Math.random() * Math.PI * 2;
288
+ // Increased dispersion: 0 - 600 offset
289
+ if (!n.orbitalRadiusOffset || n.orbitalRadiusOffset < 100) n.orbitalRadiusOffset = Math.random() * 600;
290
+ });
291
+
292
+ if (document.getElementById('layout-style').value === 'orbital') {
293
+ this.runLocalCloudLayout();
294
+ }
295
+
296
+ this.centerView();
297
+ },
298
+
299
+ // --- Animation & Rendering ---
300
+
301
+ animate: function() {
302
+ const layout = document.getElementById('layout-style').value;
303
+ if (layout === 'orbital') {
304
+ this.updateOrbitalPositions();
305
+ this.render();
306
+ }
307
+ this.animationId = requestAnimationFrame(() => this.animate());
308
+ },
309
+
310
+ updateOrbitalPositions: function() {
311
+ if (!this.centralNodeId) return;
312
+
313
+ // Cloud Logic: Each node has unique speed/radius
314
+ this.nodes.forEach(node => {
315
+ if (node.id !== this.centralNodeId) {
316
+ // Init logical radius if missing
317
+ if (node.radius === undefined) {
318
+ node.radius = 200 + (node.orbitalRadiusOffset || 50);
319
+ node.baseAngle = node.orbitalPhase || 0;
320
+ }
321
+
322
+ // Update angle
323
+ node.baseAngle += (node.orbitalSpeed || 0.001);
324
+
325
+ // Update position
326
+ node.x = node.radius * Math.cos(node.baseAngle);
327
+ node.y = node.radius * Math.sin(node.baseAngle);
328
+ } else {
329
+ node.x = 0;
330
+ node.y = 0;
331
+ }
332
+ });
333
+ },
334
+
335
+ render: function() {
336
+ if (!this.ctx) return;
337
+ const ctx = this.ctx;
338
+ const t = this.transform;
339
+ const layout = document.getElementById('layout-style').value;
340
+
341
+ ctx.save();
342
+ ctx.fillStyle = '#1e1e1e';
343
+ ctx.fillRect(0, 0, this.width, this.height);
344
+
345
+ ctx.translate(t.x, t.y);
346
+ ctx.scale(t.k, t.k);
347
+
348
+ // --- Edges with Depth of Field ---
349
+ this.links.forEach(link => {
350
+ const source = this.nodes.find(n => n.id === link.source);
351
+ const target = this.nodes.find(n => n.id === link.target);
352
+ if (source && target) {
353
+ let alpha = 0.3;
354
+ if (layout === 'orbital') {
355
+ // Only show edges connected to central clearly, others content hidden
356
+ const isCentralConn = source.id === this.centralNodeId || target.id === this.centralNodeId;
357
+ alpha = isCentralConn ? 0.6 : 0.0;
358
+ }
359
+ ctx.strokeStyle = `rgba(100, 100, 100, ${alpha})`;
360
+ ctx.lineWidth = layout === 'orbital' ? 0.5 : 1;
361
+
362
+ // Skip rendering very faint edges for perf
363
+ if (alpha > 0.01) {
364
+ ctx.beginPath();
365
+ if (layout === 'vertical' && layout !== 'orbital') {
366
+ this.drawCurve(ctx, source, target);
367
+ } else {
368
+ ctx.moveTo(source.x, source.y);
369
+ ctx.lineTo(target.x, target.y);
370
+ }
371
+ ctx.stroke();
372
+ }
373
+ }
374
+ });
375
+
376
+ // --- Nodes ---
377
+ const sortedNodes = [...this.nodes];
378
+ if (layout === 'orbital' && this.centralNodeId) {
379
+ sortedNodes.sort((a, b) => (a.id === this.centralNodeId ? 1 : -1));
380
+ }
381
+
382
+ sortedNodes.forEach(node => {
383
+ let radius = 5;
384
+ let fill = '#4a9eff';
385
+ let alpha = 1.0;
386
+ let labelSize = 4;
387
+
388
+ if (node.isCompleted) {
389
+ fill = '#ffd700';
390
+ radius = 4;
391
+ }
392
+
393
+ if (layout === 'orbital') {
394
+ if (node.id === this.centralNodeId) {
395
+ radius = 60;
396
+ fill = node.isCompleted ? '#ffd700' : '#00d2ff';
397
+ ctx.shadowBlur = 30;
398
+ ctx.shadowColor = fill;
399
+ labelSize = 14;
400
+ } else {
401
+ // Depth of Field: Opacity based on Z/Radius or just distance
402
+ // Since it's 2D cloud, we use simple distance from center to simulate DoF focus?
403
+ // Actually user wants "reduce rendering load for most low-relevance nodes"
404
+ // We can use the 'orbitalRadiusOffset' to simulate Z-depth.
405
+ // Let's assume larger radius = further away = lower opacity.
406
+
407
+ const dist = node.radius || Math.hypot(node.x, node.y);
408
+ // Updated DoF for wider dispersion (up to 1000px radius)
409
+ // High opacity for close nodes, gradual falloff for far nodes
410
+ const zFactor = Math.max(0.4, 1 - (dist / 1200));
411
+
412
+ radius = Math.max(3, 25 * zFactor);
413
+ alpha = zFactor; // Base alpha directly related to zFactor (0.4 - 1.0)
414
+
415
+ fill = node.isCompleted ? '#b8860b' : '#2c5282';
416
+ ctx.shadowBlur = 0;
417
+ labelSize = radius / 2;
418
+ }
419
+ }
420
+
421
+ // Draw
422
+ if (alpha > 0.05) { // Optimization
423
+ ctx.beginPath();
424
+ ctx.globalAlpha = alpha;
425
+ ctx.fillStyle = fill;
426
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI);
427
+ ctx.fill();
428
+
429
+ // Labels
430
+ let showLabel = false;
431
+ if (layout === 'orbital') {
432
+ showLabel = true; // Always show in orbital (user request)
433
+ } else {
434
+ showLabel = node.id === this.centralNodeId || (alpha > 0.6 && t.k > 0.8);
435
+ }
436
+
437
+ if (showLabel) {
438
+ ctx.globalAlpha = alpha > 0.5 ? 1.0 : alpha + 0.2; // Slightly boost label alpha
439
+ ctx.fillStyle = '#fff';
440
+
441
+ if (layout === 'orbital') {
442
+ // Scaled labels with limit
443
+ // Cap font size to match node dimensions (radius is approx 20-30 for peripherals)
444
+ // Use 0.5 * radius for text height approx, capped at 16px (standard reading size).
445
+ const calculatedSize = node.id === this.centralNodeId ? 20 : (radius * 0.5);
446
+ const fontSize = Math.min(16, Math.max(8, calculatedSize));
447
+
448
+ ctx.font = `${fontSize}px sans-serif`;
449
+ ctx.textAlign = 'center';
450
+ ctx.textBaseline = 'middle';
451
+ let label = node.label;
452
+ // Truncate only very long labels
453
+ if (node.id !== this.centralNodeId && label.length > 15) label = label.substring(0, 12) + '..';
454
+
455
+ // Drop shadow for readability
456
+ ctx.shadowColor = 'rgba(0,0,0,0.8)';
457
+ ctx.shadowBlur = 4;
458
+ ctx.fillText(label, node.x, node.y + (node.id === this.centralNodeId ? 0 : radius + 8));
459
+ ctx.shadowBlur = 0;
460
+ } else {
461
+ if (layout !== 'orbital' && t.k > 0.5) {
462
+ ctx.font = '4px sans-serif';
463
+ ctx.textAlign = 'left';
464
+ ctx.fillText(node.label, node.x + 8, node.y + 2);
465
+ }
466
+ }
467
+ }
468
+ }
469
+ ctx.shadowBlur = 0;
470
+ ctx.globalAlpha = 1.0;
471
+ });
472
+
473
+ ctx.restore();
474
+ },
475
+
476
+ drawCurve: function(ctx, source, target) {
477
+ ctx.moveTo(source.x, source.y);
478
+ ctx.bezierCurveTo(source.x, (source.y + target.y)/2, target.x, (source.y + target.y)/2, target.x, target.y);
479
+ },
480
+
481
+ // --- Interactions ---
482
+
483
+ handleDoubleClick: function(e) {
484
+ const { x, y } = this.getCanvasCoordinates(e.clientX, e.clientY);
485
+ const layout = document.getElementById('layout-style').value;
486
+ const node = this.findNodeAt(x, y);
487
+
488
+ if (node) {
489
+ console.log("Double Clicked:", node.label, node.id);
490
+ if (layout === 'orbital') {
491
+ if (node.id === this.centralNodeId) {
492
+ // Central Node -> Open Content
493
+ if (typeof window.reader !== 'undefined' && window.reader.open) {
494
+ try {
495
+ // Fetch full node data from global source if available to get content/metadata
496
+ let fullNode = node;
497
+ if (typeof window.graphData !== 'undefined' && window.graphData.nodes) {
498
+ const found = window.graphData.nodes.find(n => n.id === node.id);
499
+ if (found) fullNode = found;
500
+ } else if (typeof graphData !== 'undefined' && graphData.nodes) {
501
+ const found = graphData.nodes.find(n => n.id === node.id);
502
+ if (found) fullNode = found;
503
+ }
504
+
505
+ window.reader.open(fullNode);
506
+ } catch(err) { console.error("Reader Error", err); }
507
+ } else {
508
+ console.error("Reader module missing or invalid.", window.reader);
509
+ }
510
+ } else {
511
+ // Peripheral -> Switch Focus
512
+ this.switchCentral(node.id);
513
+ }
514
+ } else {
515
+ if (window.reader) window.reader.open(node.id);
516
+ }
517
+ }
518
+ },
519
+
520
+ removeHistoryItem: function(itemId, event) {
521
+ if (event) event.stopPropagation(); // Prevent opening reader
522
+
523
+ this.learningHistory = this.learningHistory.filter(n => n.id !== itemId);
524
+ this.completedNodes.delete(itemId);
525
+ this.saveHistory();
526
+ this.updateHistorySidebar();
527
+
528
+ // Update visual state of the node if visible
529
+ const liveNode = this.nodes.find(n => n.id === itemId);
530
+ if (liveNode) liveNode.isCompleted = false;
531
+ this.render();
532
+ },
533
+
534
+ markComplete: function() {
535
+ if (!this.centralNodeId) return;
536
+ const node = this.nodes.find(n => n.id === this.centralNodeId);
537
+ if (node && !node.isCompleted) {
538
+ node.isCompleted = true;
539
+ this.completedNodes.add(node.id);
540
+ // Avoid duplicates
541
+ if (!this.learningHistory.some(h => h.id === node.id)) {
542
+ this.learningHistory.push(node);
543
+ }
544
+ this.saveHistory();
545
+ this.updateHistorySidebar();
546
+
547
+ const next = this.nodes.find(n => !this.completedNodes.has(n.id) && n.id !== node.id);
548
+ if (next) setTimeout(() => this.switchCentral(next.id), 500);
549
+
550
+ this.render();
551
+ }
552
+ },
553
+
554
+ switchCentral: function(id) {
555
+ this.centralNodeId = id;
556
+ this.runLocalCloudLayout();
557
+ this.render();
558
+ this.centerView();
559
+ },
560
+
561
+ runLocalCloudLayout: function() {
562
+ if (document.getElementById('layout-style').value !== 'orbital') return;
563
+
564
+ const center = this.nodes.find(n => n.id === this.centralNodeId);
565
+ if (!center) return;
566
+
567
+ center.x = 0; center.y = 0; center.radius = 0;
568
+
569
+ const others = this.nodes.filter(n => n.id !== this.centralNodeId);
570
+
571
+ // Cloud Distribution:
572
+ // Iterate and assign random stable radii (350-950 range for max dispersion)
573
+ others.forEach((node, i) => {
574
+ const angle = (i / others.length) * 2 * Math.PI;
575
+ // Use existing offsets or init new randoms (Wide spread)
576
+ if (!node.orbitalRadiusOffset || node.orbitalRadiusOffset < 100) node.orbitalRadiusOffset = Math.random() * 600;
577
+
578
+ node.radius = 350 + node.orbitalRadiusOffset; // Base 350 (was 200)
579
+ node.baseAngle = angle;
580
+ node.orbitalPhase = node.orbitalPhase || Math.random() * 10;
581
+
582
+ node.x = node.radius * Math.cos(angle);
583
+ node.y = node.radius * Math.sin(angle);
584
+ });
585
+ },
586
+
587
+ getCanvasCoordinates: function(clientX, clientY) {
588
+ const t = this.transform;
589
+ return {
590
+ x: (clientX - t.x) / t.k,
591
+ y: (clientY - t.y) / t.k
592
+ };
593
+ },
594
+
595
+ findNodeAt: function(x, y) {
596
+ const layout = document.getElementById('layout-style').value;
597
+ if (layout === 'orbital' && this.centralNodeId) {
598
+ const center = this.nodes.find(n => n.id === this.centralNodeId);
599
+ const dist = Math.hypot(center.x - x, center.y - y);
600
+ if (dist < 65) return center;
601
+ }
602
+
603
+ return this.nodes.find(node => {
604
+ const dist = Math.hypot(node.x - x, node.y - y);
605
+ // Dynamic hit test based on visual size (approx)
606
+ // If node is faded (further away), make it harder to hit?
607
+ // Or keep it standard. Standard is safer for usability.
608
+ return dist < 20;
609
+ });
610
+ },
611
+
612
+ centerView: function() {
613
+ // ... (standard zooming)
614
+ if (this.nodes.length === 0) return;
615
+ let minX = -400, maxX = 400, minY = -400, maxY = 400; // Cloud approximate bounds
616
+
617
+ const padding = 50;
618
+ const width = maxX - minX + padding * 2;
619
+ const height = maxY - minY + padding * 2;
620
+ const scale = Math.min(this.width / width, this.height / height, 1);
621
+ const tx = this.width / 2;
622
+ const ty = this.height / 2;
623
+
624
+ const zoom = d3.zoomIdentity.translate(tx, ty).scale(scale);
625
+ d3.select(this.canvas).transition().duration(750).call(d3.zoom().transform, zoom);
626
+ this.transform = { k: scale, x: tx, y: ty };
627
+ },
628
+
629
+ showNodeSelector: function() {
630
+ const modal = document.getElementById('node-select-modal');
631
+ modal.style.display = 'flex';
632
+ document.getElementById('node-select-input').value = '';
633
+ this.filterNodeList('');
634
+ },
635
+
636
+ filterNodeList: function(query) {
637
+ const list = document.getElementById('node-select-list');
638
+ list.innerHTML = '';
639
+ const sourceData = (typeof graphData !== 'undefined') ? graphData : window.graphData;
640
+ if (!sourceData) return;
641
+
642
+ const matches = sourceData.nodes
643
+ .filter(n => n.label.toLowerCase().includes(query.toLowerCase()))
644
+ .slice(0, 300); // Increased limit from 20 to 300 for better discoverability
645
+
646
+ matches.forEach(node => {
647
+ const li = document.createElement('li');
648
+ li.innerHTML = `<span>${node.label}</span>`;
649
+ li.onclick = () => {
650
+ this.currentTargetId = node.id;
651
+ document.getElementById('node-select-modal').style.display = 'none';
652
+ this.triggerUpdate();
653
+ };
654
+ list.appendChild(li);
655
+ });
656
+ },
657
+
658
+ updateHistorySidebar: function() {
659
+ const list = document.getElementById('history-list');
660
+ list.innerHTML = '';
661
+ this.learningHistory.forEach(item => {
662
+ const div = document.createElement('div');
663
+ div.className = 'history-item';
664
+ div.style.display = 'flex';
665
+ div.style.justifyContent = 'space-between';
666
+ div.style.alignItems = 'center';
667
+
668
+ const labelSpan = document.createElement('span');
669
+ labelSpan.innerText = item.label;
670
+ labelSpan.style.cursor = 'pointer';
671
+ labelSpan.onclick = () => { if (window.reader) window.reader.open(item.id); };
672
+
673
+ const removeBtn = document.createElement('span');
674
+ removeBtn.innerHTML = '&times;';
675
+ removeBtn.style.color = '#ff6b6b';
676
+ removeBtn.style.cursor = 'pointer';
677
+ removeBtn.style.padding = '0 5px';
678
+ removeBtn.onclick = (e) => this.removeHistoryItem(item.id, e);
679
+
680
+ div.appendChild(labelSpan);
681
+ div.appendChild(removeBtn);
682
+ list.appendChild(div);
683
+ });
684
+ }
685
+ };