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.
- package/README.md +81 -3
- package/dist/src/core/Graph.js +84 -0
- package/dist/src/core/PathBridge.js +49 -0
- package/dist/src/core/PathEngine.js +196 -0
- package/dist/src/core/PathEngine.test.js +86 -0
- package/dist/src/electron/main.js +14 -0
- package/dist/src/frontend/README.md +81 -3
- package/dist/src/frontend/app.js +39 -0
- package/dist/src/frontend/index.html +128 -2
- package/dist/src/frontend/libs/path_core.js +429 -0
- package/dist/src/frontend/locales/en.json +52 -29
- package/dist/src/frontend/locales/zh.json +30 -7
- package/dist/src/frontend/path.html +100 -0
- package/dist/src/frontend/path_app.js +685 -0
- package/dist/src/frontend/path_styles.css +240 -0
- package/dist/src/frontend/path_worker.js +176 -0
- package/dist/src/frontend/styles.css +1 -1
- package/package.json +7 -3
|
@@ -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 = '×';
|
|
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
|
+
};
|