noteconnection 0.9.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/backend/CommunityDetection.js +58 -0
  4. package/dist/backend/FileLoader.js +110 -0
  5. package/dist/backend/GraphBuilder.js +347 -0
  6. package/dist/backend/GraphMetrics.js +70 -0
  7. package/dist/backend/algorithms/CycleDetection.js +63 -0
  8. package/dist/backend/algorithms/HybridEngine.js +70 -0
  9. package/dist/backend/algorithms/StatisticalAnalyzer.js +123 -0
  10. package/dist/backend/algorithms/TopologicalSort.js +69 -0
  11. package/dist/backend/algorithms/VectorSpace.js +87 -0
  12. package/dist/backend/build_dag.js +164 -0
  13. package/dist/backend/config.js +17 -0
  14. package/dist/backend/graph.js +108 -0
  15. package/dist/backend/main.js +67 -0
  16. package/dist/backend/parser.js +94 -0
  17. package/dist/backend/test_robustness/test_hybrid.js +60 -0
  18. package/dist/backend/test_robustness/test_statistics.js +58 -0
  19. package/dist/backend/test_robustness/test_vector.js +54 -0
  20. package/dist/backend/test_robustness.js +113 -0
  21. package/dist/backend/types.js +3 -0
  22. package/dist/backend/utils/frontmatterParser.js +121 -0
  23. package/dist/backend/utils/stringUtils.js +66 -0
  24. package/dist/backend/workers/keywordMatchWorker.js +22 -0
  25. package/dist/core/Graph.js +121 -0
  26. package/dist/core/Graph.test.js +37 -0
  27. package/dist/core/types.js +2 -0
  28. package/dist/frontend/analysis.js +356 -0
  29. package/dist/frontend/app.js +1447 -0
  30. package/dist/frontend/data.js +8356 -0
  31. package/dist/frontend/graph_data.json +8356 -0
  32. package/dist/frontend/index.html +279 -0
  33. package/dist/frontend/reader.js +177 -0
  34. package/dist/frontend/settings.js +84 -0
  35. package/dist/frontend/source_manager.js +61 -0
  36. package/dist/frontend/styles.css +577 -0
  37. package/dist/frontend/styles_analysis.css +145 -0
  38. package/dist/index.js +121 -0
  39. package/dist/server.js +149 -0
  40. package/package.json +39 -0
@@ -0,0 +1,1447 @@
1
+ // Initialize Graph
2
+ const container = document.getElementById('graph-container');
3
+ let focusNode = null;
4
+
5
+ // State for Cluster Filtering
6
+ let activeClusterFilter = localStorage.getItem('activeClusterFilter') || 'all';
7
+ // Clear it immediately so it doesn't persist unwantedly on manual refreshes?
8
+ // No, user might want to refresh. We need a UI to clear it.
9
+
10
+ // Create SVG with 100% dimensions
11
+ const svg = d3.select("#graph-container")
12
+ .append("svg")
13
+ .attr("width", "100%")
14
+ .attr("height", "100%")
15
+ .call(d3.zoom().on("zoom", (event) => {
16
+ g.attr("transform", event.transform);
17
+ }));
18
+
19
+ const g = svg.append("g");
20
+
21
+ // Tooltip
22
+ const tooltip = d3.select("body").append("div")
23
+ .attr("class", "tooltip")
24
+ .style("opacity", 0);
25
+
26
+ // Data
27
+ const nodes = graphData.nodes.map(d => Object.create(d));
28
+ const links = graphData.edges.map(d => Object.create(d));
29
+
30
+ // Update stats
31
+ document.getElementById('node-count').innerText = nodes.length;
32
+ document.getElementById('edge-count').innerText = links.length;
33
+
34
+ // Inject Filter Reset UI if needed
35
+ if (activeClusterFilter !== 'all') {
36
+ const controls = document.getElementById('controls');
37
+ const filterMsg = document.createElement('div');
38
+ filterMsg.style.background = '#742a2a';
39
+ filterMsg.style.color = 'white';
40
+ filterMsg.style.padding = '5px';
41
+ filterMsg.style.marginTop = '10px';
42
+ filterMsg.style.borderRadius = '4px';
43
+ filterMsg.style.fontSize = '0.85rem';
44
+ filterMsg.style.display = 'flex';
45
+ filterMsg.style.justifyContent = 'space-between';
46
+ filterMsg.style.alignItems = 'center';
47
+ filterMsg.innerHTML = `<span>Filter: <b>${activeClusterFilter}</b></span> <button id="clear-cluster-filter" style="font-size:0.8em; cursor:pointer;">X</button>`;
48
+
49
+ // Insert after Search box
50
+ const searchBox = document.querySelector('.search-box');
51
+ searchBox.parentNode.insertBefore(filterMsg, searchBox.nextSibling);
52
+
53
+ setTimeout(() => {
54
+ document.getElementById('clear-cluster-filter').addEventListener('click', () => {
55
+ localStorage.removeItem('activeClusterFilter');
56
+ window.location.reload();
57
+ });
58
+ }, 100);
59
+ }
60
+
61
+ // Initialize Controls
62
+ const maxDegree = d3.max(nodes, d => d.inDegree + d.outDegree) || 0;
63
+ const minDegreeSlider = document.getElementById('min-degree-slider');
64
+ minDegreeSlider.max = maxDegree;
65
+ document.getElementById('min-degree-val').innerText = minDegreeSlider.value;
66
+
67
+ // Simulation
68
+ // Initial Center
69
+ let width = container.clientWidth;
70
+ let height = container.clientHeight;
71
+
72
+ const simulation = d3.forceSimulation(nodes)
73
+ .force("link", d3.forceLink(links).id(d => d.id).distance(100))
74
+ .force("charge", d3.forceManyBody().strength(-300))
75
+ .force("center", d3.forceCenter(width / 2, height / 2))
76
+ .force("collide", d3.forceCollide().radius(20)); // Avoid overlap
77
+
78
+ // Handle Resize
79
+ const resizeObserver = new ResizeObserver(entries => {
80
+ for (let entry of entries) {
81
+ width = entry.contentRect.width;
82
+ height = entry.contentRect.height;
83
+
84
+ const mode = document.querySelector('input[name="layoutMode"]:checked') ? document.querySelector('input[name="layoutMode"]:checked').value : 'force';
85
+
86
+ if (mode === 'dag') {
87
+ // Update X centering
88
+ simulation.force("x", d3.forceX(width / 2).strength(0.05));
89
+ } else {
90
+ // Update Center Force
91
+ simulation.force("center", d3.forceCenter(width / 2, height / 2));
92
+ }
93
+ simulation.alpha(0.3).restart();
94
+ }
95
+ });
96
+ resizeObserver.observe(container);
97
+
98
+ // Arrows for edges
99
+ svg.append("defs").selectAll("marker")
100
+ .data(["end"])
101
+ .enter().append("marker")
102
+ .attr("id", "arrow")
103
+ .attr("viewBox", "0 -5 10 10")
104
+ .attr("refX", 15) // Position of arrow
105
+ .attr("refY", 0)
106
+ .attr("markerWidth", 6)
107
+ .attr("markerHeight", 6)
108
+ .attr("orient", "auto")
109
+ .append("path")
110
+ .attr("d", "M0,-5L10,0L0,5")
111
+ .attr("fill", "#555");
112
+
113
+ // Render Links
114
+ const link = g.append("g")
115
+ .attr("class", "links")
116
+ .selectAll("path")
117
+ .data(links)
118
+ .enter().append("path")
119
+ .attr("class", "link")
120
+ .attr("marker-end", "url(#arrow)");
121
+
122
+ // Render Nodes
123
+ const node = g.append("g")
124
+ .attr("class", "nodes")
125
+ .selectAll("g")
126
+ .data(nodes)
127
+ .enter().append("g")
128
+ .attr("class", "node")
129
+ .call(d3.drag()
130
+ .on("start", dragstarted)
131
+ .on("drag", dragged)
132
+ .on("end", dragended));
133
+
134
+ // Node Circles (Color by degree)
135
+ // Scales
136
+ const colorScaleDegree = d3.scaleSequential(d3.interpolateBlues)
137
+ .domain([0, maxDegree]);
138
+
139
+ const uniqueClusters = Array.from(new Set(nodes.map(d => d.clusterId))).sort();
140
+ const colorScaleCluster = d3.scaleOrdinal(d3.schemeCategory10)
141
+ .domain(uniqueClusters);
142
+
143
+ // Size Scale
144
+ const maxCentrality = d3.max(nodes, d => d.centrality || 0) || 1;
145
+ const sizeScaleCentrality = d3.scaleSqrt()
146
+ .domain([0, maxCentrality])
147
+ .range([3, 12]); // Min 3px, Max 12px
148
+
149
+ const circles = node.append("circle")
150
+ .attr("r", 5);
151
+
152
+ // Labels
153
+ const texts = node.append("text")
154
+ .attr("dx", 8)
155
+ .attr("dy", ".35em")
156
+ .text(d => d.label);
157
+
158
+ // Initial State
159
+ updateColor();
160
+ updateSize();
161
+
162
+ // Helper to get degree based on selection
163
+ function getDegree(d) {
164
+ const mode = document.querySelector('input[name="degreeMode"]:checked').value;
165
+ if (mode === 'in') return d.inDegree || 0;
166
+ if (mode === 'out') return d.outDegree || 0;
167
+ return (d.inDegree || 0) + (d.outDegree || 0);
168
+ }
169
+
170
+ function updateColor() {
171
+ const mode = document.querySelector('input[name="colorMode"]:checked').value;
172
+ if (mode === 'cluster') {
173
+ circles.attr("fill", d => colorScaleCluster(d.clusterId || 'unknown'));
174
+ } else {
175
+ // Update domain based on current max degree
176
+ const maxDeg = d3.max(nodes, d => getDegree(d)) || 1;
177
+ colorScaleDegree.domain([0, maxDeg]);
178
+ circles.attr("fill", d => colorScaleDegree(getDegree(d)));
179
+ }
180
+ }
181
+
182
+ function updateSize() {
183
+ const mode = document.querySelector('input[name="sizeMode"]:checked').value;
184
+
185
+ if (mode === 'centrality') {
186
+ // Node Size by Centrality
187
+ circles.transition().duration(300).attr("r", d => sizeScaleCentrality(d.centrality || 0));
188
+
189
+ texts.transition().duration(300)
190
+ .attr("font-size", d => Math.max(10, sizeScaleCentrality(d.centrality || 0) * 1.2) + "px")
191
+ .attr("font-weight", d => (d.centrality || 0) > maxCentrality * 0.5 ? "bold" : "normal")
192
+ .attr("dx", d => sizeScaleCentrality(d.centrality || 0) + 4);
193
+
194
+ simulation.force("collide", d3.forceCollide().radius(d => sizeScaleCentrality(d.centrality || 0) + 5));
195
+
196
+ } else if (mode === 'degree') {
197
+ // Node Size by Degree
198
+ const maxDeg = d3.max(nodes, d => getDegree(d)) || 1;
199
+ const sizeScaleDegree = d3.scaleSqrt().domain([0, maxDeg]).range([3, 12]);
200
+
201
+ circles.transition().duration(300).attr("r", d => sizeScaleDegree(getDegree(d)));
202
+
203
+ texts.transition().duration(300)
204
+ .attr("font-size", d => Math.max(10, sizeScaleDegree(getDegree(d)) * 1.2) + "px")
205
+ .attr("dx", d => sizeScaleDegree(getDegree(d)) + 4);
206
+
207
+ simulation.force("collide", d3.forceCollide().radius(d => sizeScaleDegree(getDegree(d)) + 5));
208
+
209
+ } else {
210
+ // Uniform
211
+ circles.transition().duration(300).attr("r", 5);
212
+ texts.transition().duration(300)
213
+ .attr("font-size", "10px")
214
+ .attr("font-weight", "normal")
215
+ .attr("dx", 8);
216
+
217
+ simulation.force("collide", d3.forceCollide().radius(8));
218
+ }
219
+ simulation.alpha(0.3).restart();
220
+ }
221
+
222
+ function updateLayout() {
223
+ const mode = document.querySelector('input[name="layoutMode"]:checked').value;
224
+
225
+ if (mode === 'dag') {
226
+ // DAG Layout: Vertical layering based on Rank
227
+ const layerHeight = 120; // Pixels per rank
228
+
229
+ // Remove standard Center force
230
+ simulation.force("center", null);
231
+
232
+ // Add Hierarchical forces
233
+ // Force Y: Strong pull to rank-based layer
234
+ simulation.force("y", d3.forceY(d => (d.rank || 0) * layerHeight).strength(1));
235
+
236
+ // Force X: Weak pull to center X to keep tree compact, but allow spread
237
+ simulation.force("x", d3.forceX(width / 2).strength(0.05));
238
+
239
+ // Modify Link force: Reduce strength so layers don't collapse
240
+ simulation.force("link").distance(100).strength(0.3);
241
+
242
+ // Charge: Keep repulsion to avoid overlap within layers
243
+ simulation.force("charge").strength(-300);
244
+
245
+ } else {
246
+ // Force Layout (Default)
247
+ // Remove DAG forces
248
+ simulation.force("y", null);
249
+ simulation.force("x", null);
250
+
251
+ // Restore Standard forces
252
+ simulation.force("center", d3.forceCenter(width / 2, height / 2));
253
+
254
+ // Re-initialize Link Force to restore default strength calculation
255
+ simulation.force("link", d3.forceLink(links).id(d => d.id).distance(100));
256
+
257
+ simulation.force("charge").strength(-300);
258
+ }
259
+
260
+ simulation.alpha(1).restart();
261
+ }
262
+
263
+ // Listeners
264
+ document.querySelectorAll('input[name="layoutMode"]').forEach(radio => {
265
+ radio.addEventListener('change', updateLayout);
266
+ });
267
+
268
+ document.querySelectorAll('input[name="colorMode"]').forEach(radio => {
269
+ radio.addEventListener('change', updateColor);
270
+ });
271
+ document.querySelectorAll('input[name="sizeMode"]').forEach(radio => {
272
+ radio.addEventListener('change', updateSize);
273
+ });
274
+ document.querySelectorAll('input[name="degreeMode"]').forEach(radio => {
275
+ radio.addEventListener('change', () => {
276
+ updateColor(); // Color might depend on degree mode
277
+ updateSize(); // Size might depend on degree mode
278
+ });
279
+ });
280
+
281
+ // Localization
282
+ const translations = {
283
+ zh: {
284
+ show_all: "显示全部",
285
+ show_in: "仅入度",
286
+ show_out: "仅出度",
287
+ view_mode: "视图模式:",
288
+ view_nodes: "节点",
289
+ view_clusters: "聚类 (概览)",
290
+ degree_basis: "度数基准:",
291
+ all: "总",
292
+ in: "入",
293
+ out: "出",
294
+ color_by: "颜色依据:",
295
+ degree: "度数",
296
+ cluster: "聚类",
297
+ size_by: "大小依据:",
298
+ uniform: "统一",
299
+ centrality: "中心性",
300
+ nodes: "节点:",
301
+ edges: "边:",
302
+ label_opacity: "标签透明度:",
303
+ min_degree: "最小度数:",
304
+ show_orphans: "显示孤立节点",
305
+ export_image: "导出图片",
306
+ save_layout: "保存布局 (JSON)",
307
+ analysis_export: "分析与导出",
308
+ search_placeholder: "搜索节点...",
309
+ layout: "布局:",
310
+ layout_force: "力导向",
311
+ layout_dag: "DAG (层级)",
312
+
313
+ // Analysis Panel
314
+ analysis_title: "度数分析",
315
+ filter_strategy: "过滤策略:",
316
+ cluster_filter: "聚类过滤:",
317
+ threshold: "阈值:",
318
+ selected: "已选:",
319
+ export_json: "JSON",
320
+ export_zip: "ZIP (MD)",
321
+ filtered_nodes: "过滤后节点",
322
+
323
+ // Strategy Options
324
+ strat_top: "Top X% (按度数)",
325
+ strat_min: "最小度数 > X",
326
+ cluster_all: "所有聚类",
327
+
328
+ // Table Headers
329
+ th_name: "名称",
330
+ th_cluster: "聚类",
331
+ th_in: "入",
332
+ th_out: "出",
333
+ th_total: "总计",
334
+
335
+ // Settings
336
+ settings_title: "可视化设置",
337
+ btn_settings: "设置",
338
+ grp_physics: "物理模拟",
339
+ grp_visuals: "视觉外观",
340
+ lbl_repulsion: "排斥力",
341
+ lbl_distance: "连接长度",
342
+ lbl_collision: "碰撞半径",
343
+ lbl_opacity: "边透明度",
344
+ btn_reset: "重置默认",
345
+ btn_done: "完成",
346
+
347
+ // Reader
348
+ grp_reading: "阅读窗口",
349
+ lbl_reading_mode: "打开模式",
350
+ opt_window: "窗口",
351
+ opt_fullscreen: "全屏",
352
+
353
+ // Focus Mode
354
+ exit_focus: "退出专注模式",
355
+ auto_arrange: "自动排列",
356
+
357
+ // Simulation
358
+ simulation: "物理模拟",
359
+ freeze_layout: "冻结布局 (停止刷新)",
360
+ speed: "速度 (阻尼):"
361
+ },
362
+ en: {
363
+ show_all: "Show All",
364
+ show_in: "Incoming Only",
365
+ show_out: "Outgoing Only",
366
+ view_mode: "View Mode:",
367
+ view_nodes: "Nodes",
368
+ view_clusters: "Clusters (Overview)",
369
+ degree_basis: "Degree Basis:",
370
+ all: "All",
371
+ in: "In",
372
+ out: "Out",
373
+ color_by: "Color By:",
374
+ degree: "Degree",
375
+ cluster: "Cluster",
376
+ size_by: "Size By:",
377
+ uniform: "Uniform",
378
+ centrality: "Centrality",
379
+ nodes: "Nodes:",
380
+ edges: "Edges:",
381
+ label_opacity: "Label Opacity:",
382
+ min_degree: "Min Degree:",
383
+ show_orphans: "Show Orphans",
384
+ export_image: "Export Image",
385
+ save_layout: "Save Layout (JSON)",
386
+ analysis_export: "Analysis & Export",
387
+ search_placeholder: "Search node...",
388
+ layout: "Layout:",
389
+ layout_force: "Force",
390
+ layout_dag: "DAG (Hierarchical)",
391
+
392
+ // Analysis Panel
393
+ analysis_title: "Degree Analysis",
394
+ filter_strategy: "Filter Strategy:",
395
+ cluster_filter: "Cluster Filter:",
396
+ threshold: "Threshold:",
397
+ selected: "Selected:",
398
+ export_json: "JSON",
399
+ export_zip: "ZIP (MD)",
400
+ filtered_nodes: "Filtered Nodes",
401
+
402
+ // Strategy Options
403
+ strat_top: "Top X% (by Degree)",
404
+ strat_min: "Min Degree > X",
405
+ cluster_all: "All Clusters",
406
+
407
+ // Table Headers
408
+ th_name: "Name",
409
+ th_cluster: "Cluster",
410
+ th_in: "In",
411
+ th_out: "Out",
412
+ th_total: "Total",
413
+
414
+ // Settings
415
+ settings_title: "Visualization Settings",
416
+ btn_settings: "Settings",
417
+ grp_physics: "Physics Simulation",
418
+ grp_visuals: "Visual Appearance",
419
+ lbl_repulsion: "Repulsion",
420
+ lbl_distance: "Link Length",
421
+ lbl_collision: "Collision Radius",
422
+ lbl_opacity: "Edge Opacity",
423
+ btn_reset: "Reset Defaults",
424
+ btn_done: "Done",
425
+
426
+ // Reader
427
+ grp_reading: "Reading Window",
428
+ lbl_reading_mode: "Open Mode",
429
+ opt_window: "Window",
430
+ opt_fullscreen: "Full Screen",
431
+
432
+ // Focus Mode
433
+ exit_focus: "Exit Focus Mode",
434
+
435
+ // Simulation
436
+ simulation: "Simulation",
437
+ freeze_layout: "Freeze Layout",
438
+ speed: "Speed (Damping):"
439
+ }
440
+ };
441
+
442
+ window.t = function(key) {
443
+ const lang = document.getElementById('lang-select').value;
444
+ return translations[lang][key] || key;
445
+ }
446
+
447
+ window.updateLanguage = function(lang) {
448
+ document.querySelectorAll('[data-i18n]').forEach(el => {
449
+ const key = el.dataset.i18n;
450
+ if (translations[lang] && translations[lang][key]) {
451
+ el.innerText = translations[lang][key];
452
+ }
453
+ });
454
+
455
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
456
+ const key = el.dataset.i18nPlaceholder;
457
+ if (translations[lang] && translations[lang][key]) {
458
+ el.placeholder = translations[lang][key];
459
+ }
460
+ });
461
+
462
+ // Trigger update for Analysis Panel components if they exist
463
+ if (typeof window.updateAnalysisUI === 'function') {
464
+ window.updateAnalysisUI();
465
+ }
466
+ }
467
+
468
+ document.getElementById('lang-select').addEventListener('change', (e) => {
469
+ window.updateLanguage(e.target.value);
470
+ });
471
+
472
+
473
+ // Aggregation Logic for Cluster View
474
+ let clusterNodes = [];
475
+ let clusterLinks = [];
476
+
477
+ function buildClusterGraph() {
478
+ const clusters = new Map();
479
+
480
+ // 1. Create Cluster Nodes
481
+ nodes.forEach(n => {
482
+ const cId = n.clusterId || 'unknown';
483
+ if (!clusters.has(cId)) {
484
+ clusters.set(cId, {
485
+ id: cId,
486
+ label: cId,
487
+ count: 0,
488
+ x: n.x, y: n.y, // Initial pos
489
+ clusterId: cId
490
+ });
491
+ }
492
+ clusters.get(cId).count++;
493
+ });
494
+
495
+ clusterNodes = Array.from(clusters.values());
496
+
497
+ // 2. Create Cluster Links
498
+ const linkMap = new Map();
499
+ links.forEach(l => {
500
+ const sourceCluster = l.source.clusterId || 'unknown';
501
+ const targetCluster = l.target.clusterId || 'unknown';
502
+
503
+ if (sourceCluster !== targetCluster) {
504
+ const key = sourceCluster < targetCluster
505
+ ? `${sourceCluster}|${targetCluster}`
506
+ : `${targetCluster}|${sourceCluster}`;
507
+
508
+ if (!linkMap.has(key)) {
509
+ linkMap.set(key, { source: sourceCluster, target: targetCluster, weight: 0 });
510
+ }
511
+ linkMap.get(key).weight++;
512
+ }
513
+ });
514
+
515
+ clusterLinks = Array.from(linkMap.values());
516
+ }
517
+
518
+ function updateViewMode() {
519
+ const mode = document.querySelector('input[name="viewMode"]:checked').value;
520
+
521
+ // Stop current simulation
522
+ simulation.stop();
523
+
524
+ if (mode === 'clusters') {
525
+ if (clusterNodes.length === 0) buildClusterGraph();
526
+
527
+ // Update Data
528
+ link.data(clusterLinks, d => d.source + "-" + d.target).exit().remove();
529
+ const linkEnter = link.data(clusterLinks, d => d.source + "-" + d.target).enter().append("path")
530
+ .attr("class", "link")
531
+ .attr("stroke-width", d => Math.sqrt(d.weight)) // Thicker links for more connections
532
+ .attr("marker-end", "url(#arrow)");
533
+ // Merge
534
+ // Note: We need to re-select 'link' properly
535
+ // Simplify: Clear and rebuild for prototype
536
+ g.select(".links").selectAll("*").remove();
537
+ g.select(".nodes").selectAll("*").remove();
538
+
539
+ const newLinks = g.select(".links").selectAll("path")
540
+ .data(clusterLinks)
541
+ .enter().append("path")
542
+ .attr("class", "link")
543
+ .attr("stroke-width", d => Math.min(5, Math.sqrt(d.weight || 1)))
544
+ .attr("marker-end", "url(#arrow)");
545
+
546
+ const newNodes = g.select(".nodes").selectAll("g")
547
+ .data(clusterNodes)
548
+ .enter().append("g")
549
+ .attr("class", "node")
550
+ .call(d3.drag()
551
+ .on("start", dragstarted)
552
+ .on("drag", dragged)
553
+ .on("end", dragended));
554
+
555
+ newNodes.append("circle")
556
+ .attr("r", d => Math.sqrt(d.count) * 3 + 5) // Size by count
557
+ .attr("fill", d => colorScaleCluster(d.id));
558
+
559
+ newNodes.append("text")
560
+ .attr("dx", d => Math.sqrt(d.count) * 3 + 8)
561
+ .attr("dy", ".35em")
562
+ .text(d => `${d.label} (${d.count})`);
563
+
564
+ // Restart Simulation
565
+ simulation.nodes(clusterNodes);
566
+ simulation.force("link").links(clusterLinks).distance(150);
567
+ simulation.force("charge").strength(-500); // Stronger repulsion for big bubbles
568
+ simulation.force("collide").radius(d => Math.sqrt(d.count) * 3 + 20);
569
+
570
+ // Click to drill down
571
+ newNodes.on("click", (event, d) => {
572
+ // Drill down into cluster
573
+ localStorage.setItem('activeClusterFilter', d.id);
574
+ window.location.reload();
575
+ });
576
+
577
+ } else {
578
+ // Nodes Mode (Restore)
579
+ g.select(".links").selectAll("*").remove();
580
+ g.select(".nodes").selectAll("*").remove();
581
+
582
+ // Rebuild standard graph
583
+ // This is a bit brute force but safe
584
+ const restoreLinks = g.select(".links").selectAll("path")
585
+ .data(links)
586
+ .enter().append("path")
587
+ .attr("class", "link")
588
+ .attr("marker-end", "url(#arrow)");
589
+
590
+ const restoreNodes = g.select(".nodes").selectAll("g")
591
+ .data(nodes)
592
+ .enter().append("g")
593
+ .attr("class", "node")
594
+ .call(d3.drag()
595
+ .on("start", dragstarted)
596
+ .on("drag", dragged)
597
+ .on("end", dragended));
598
+
599
+ // Add circles and texts back
600
+ // Note: The global 'circles' and 'texts' variables need re-binding or we just re-run initial setup
601
+ // For simplicity, we just reload the page? No, let's re-append.
602
+
603
+ const c = restoreNodes.append("circle").attr("r", 5);
604
+ const t = restoreNodes.append("text").attr("dx", 8).attr("dy", ".35em").text(d => d.label);
605
+
606
+ // Re-assign globals if needed by other functions (like updateColor)
607
+ // In this architecture, 'node', 'link', 'circles', 'texts' are const selections.
608
+ // We can't reassign const.
609
+ // We should have used let.
610
+ // FIX: We need to reload the page or restructure the app to support dynamic data swapping better.
611
+ // FOR NOW: Let's just reload the page if going back to Nodes, OR better:
612
+ // Use a wrapper function `render(dataNodes, dataLinks)`
613
+
614
+ location.reload(); // Simplest robust way to restore full graph state for now
615
+ return;
616
+ }
617
+
618
+ simulation.alpha(1).restart();
619
+ }
620
+
621
+ document.querySelectorAll('input[name="viewMode"]').forEach(radio => {
622
+ radio.addEventListener('change', updateViewMode);
623
+ });
624
+
625
+
626
+ // Simulation Controls
627
+ const simSpeedSlider = document.getElementById('sim-speed-slider');
628
+ const simSpeedVal = document.getElementById('sim-speed-val');
629
+ const freezeLayoutCheckbox = document.getElementById('freeze-layout');
630
+
631
+ if (simSpeedSlider) {
632
+ simSpeedSlider.addEventListener('input', (e) => {
633
+ const val = parseFloat(e.target.value);
634
+ simSpeedVal.innerText = val;
635
+ // D3 velocityDecay: 1 = frictionless, 0 = frozen? No.
636
+ // D3: velocityDecay(0.4) is default.
637
+ // We map slider 0-1 to reasonable decay.
638
+ // Let's treat slider as "Friction": 1 = high friction (stop), 0 = low friction.
639
+ // Actually, d3.velocityDecay corresponds to (1 - friction) per tick.
640
+ // Standard range [0, 1].
641
+ simulation.velocityDecay(val);
642
+ simulation.alphaTarget(0.3).restart();
643
+ });
644
+ }
645
+
646
+ if (freezeLayoutCheckbox) {
647
+ freezeLayoutCheckbox.addEventListener('change', (e) => {
648
+ if (e.target.checked) {
649
+ simulation.stop();
650
+ // Optional: Fix all nodes in place to be sure?
651
+ // simulation.nodes().forEach(d => { d.fx = d.x; d.fy = d.y; });
652
+ } else {
653
+ // Release nodes? Only if we fixed them.
654
+ // For now, just restart.
655
+ simulation.alphaTarget(0.3).restart();
656
+ }
657
+ });
658
+ }
659
+
660
+ // Interactions
661
+ let transform = d3.zoomIdentity;
662
+
663
+ // Highlight Logic
664
+ node.on("mouseover", function(event, d) {
665
+ // 1. Lock position to prevent drift while inspecting
666
+ if (!focusNode && !freezeLayoutCheckbox.checked) {
667
+ d.fx = d.x;
668
+ d.fy = d.y;
669
+ }
670
+
671
+ // 2. Global Hover State
672
+ window.hoverNode = d;
673
+ ticked(); // Force render update for Canvas to show hover edges
674
+
675
+ const mode = document.querySelector('input[name="mode"]:checked').value;
676
+
677
+ // Dim all
678
+ node.style("opacity", 0.1);
679
+ link.style("opacity", 0); // Hide all first
680
+
681
+ // Highlight current
682
+ d3.select(this).style("opacity", 1).classed("highlight-main", true);
683
+
684
+ // Find neighbors
685
+ const connectedLinks = links.filter(l => l.source.id === d.id || l.target.id === d.id);
686
+ const connectedNodeIds = new Set();
687
+ connectedNodeIds.add(d.id);
688
+
689
+ connectedLinks.forEach(l => {
690
+ const isOutgoing = l.source.id === d.id;
691
+ const isIncoming = l.target.id === d.id;
692
+
693
+ if (mode === 'in' && !isIncoming) return;
694
+ if (mode === 'out' && !isOutgoing) return;
695
+
696
+ // Highlight Link
697
+ const linkSel = link.filter(ld => ld === l);
698
+ linkSel.style("opacity", 1)
699
+ .classed("highlight-out", isOutgoing)
700
+ .classed("highlight-in", isIncoming);
701
+
702
+ // Add neighbor ID
703
+ connectedNodeIds.add(l.source.id);
704
+ connectedNodeIds.add(l.target.id);
705
+ });
706
+
707
+ // Highlight Neighbors
708
+ node.filter(n => connectedNodeIds.has(n.id))
709
+ .style("opacity", 1);
710
+
711
+ // Tooltip
712
+ tooltip.transition().duration(200).style("opacity", .9);
713
+ tooltip.html(`
714
+ <strong>${d.label}</strong><br/>
715
+ In-Degree: ${d.inDegree}<br/>
716
+ Out-Degree: ${d.outDegree}
717
+ `)
718
+ .style("left", (event.pageX + 10) + "px")
719
+ .style("top", (event.pageY - 28) + "px");
720
+
721
+ }).on("mouseout", function(event, d) {
722
+ // 1. Unlock position (unless focused or globally frozen)
723
+ if (!focusNode && !freezeLayoutCheckbox.checked) {
724
+ d.fx = null;
725
+ d.fy = null;
726
+ }
727
+
728
+ // 2. Clear Hover State
729
+ window.hoverNode = null;
730
+ ticked();
731
+
732
+ // Reset styles to filtered state
733
+ tooltip.transition().duration(500).style("opacity", 0);
734
+ d3.select(this).classed("highlight-main", false);
735
+ link.classed("highlight-out", false).classed("highlight-in", false);
736
+ updateVisibility(); // Restore visibility based on filters
737
+ });
738
+
739
+ // Canvas Setup
740
+ const canvas = document.getElementById('graph-canvas');
741
+ const ctx = canvas.getContext('2d');
742
+ let currentTransform = d3.zoomIdentity;
743
+
744
+ // Resize Canvas
745
+ function resizeCanvas() {
746
+ canvas.width = container.clientWidth;
747
+ canvas.height = container.clientHeight;
748
+ if (document.querySelector('input[name="rendererMode"]:checked').value === 'canvas') {
749
+ ticked();
750
+ }
751
+ }
752
+ window.addEventListener('resize', resizeCanvas);
753
+ resizeCanvas();
754
+
755
+ // Canvas Zoom
756
+ d3.select(canvas).call(d3.zoom()
757
+ .scaleExtent([0.1, 8])
758
+ .on("zoom", (event) => {
759
+ currentTransform = event.transform;
760
+ ticked();
761
+ }));
762
+
763
+
764
+ // Simulation Tick
765
+ function ticked() {
766
+ const renderer = document.querySelector('input[name="rendererMode"]:checked').value;
767
+ const layoutMode = document.querySelector('input[name="layoutMode"]:checked').value;
768
+
769
+ if (renderer === 'svg') {
770
+ // SVG Update Logic
771
+ if (layoutMode === 'dag') {
772
+ link.attr("d", d => {
773
+ const sx = d.source.x;
774
+ const sy = d.source.y;
775
+ const tx = d.target.x;
776
+ const ty = d.target.y;
777
+ return `M${sx},${sy} C${sx},${(sy + ty) / 2} ${tx},${(sy + ty) / 2} ${tx},${ty}`;
778
+ });
779
+ } else {
780
+ link.attr("d", d => `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`);
781
+ }
782
+ node.attr("transform", d => `translate(${d.x},${d.y})`);
783
+ } else {
784
+ // Canvas Update Logic
785
+ renderCanvas(layoutMode);
786
+ }
787
+ }
788
+
789
+ function renderCanvas(layoutMode) {
790
+ ctx.save();
791
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
792
+
793
+ // Apply Zoom/Pan
794
+ ctx.translate(currentTransform.x, currentTransform.y);
795
+ ctx.scale(currentTransform.k, currentTransform.k);
796
+
797
+ // Draw Links
798
+ // Logic: Default Hidden (0). Visible if Focus Mode OR Hover.
799
+ // We iterate links and check visibility per link.
800
+ ctx.lineWidth = 1;
801
+
802
+ links.forEach(d => {
803
+ // Check Visibility
804
+ // 1. Focus Mode
805
+ if (focusNode) {
806
+ if (d.source.isFocusVisible === false || d.target.isFocusVisible === false) return;
807
+ ctx.globalAlpha = 0.6;
808
+ ctx.strokeStyle = "#555";
809
+ }
810
+ // 2. Hover Mode (Global hoverNode variable needed)
811
+ else if (window.hoverNode) {
812
+ if (d.source.id === window.hoverNode.id || d.target.id === window.hoverNode.id) {
813
+ ctx.globalAlpha = 0.8;
814
+ ctx.strokeStyle = "#888"; // Highlight color
815
+ } else {
816
+ return; // Hide others
817
+ }
818
+ }
819
+ else {
820
+ return; // Default Hidden
821
+ }
822
+
823
+ ctx.beginPath();
824
+ if (layoutMode === 'dag') {
825
+ const sx = d.source.x;
826
+ const sy = d.source.y;
827
+ const tx = d.target.x;
828
+ const ty = d.target.y;
829
+ const cp1x = sx;
830
+ const cp1y = (sy + ty) / 2;
831
+ const cp2x = tx;
832
+ const cp2y = (sy + ty) / 2;
833
+ ctx.moveTo(sx, sy);
834
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, tx, ty);
835
+ } else {
836
+ ctx.moveTo(d.source.x, d.source.y);
837
+ ctx.lineTo(d.target.x, d.target.y);
838
+ }
839
+ ctx.stroke();
840
+ });
841
+
842
+ // Draw Nodes
843
+ ctx.globalAlpha = 1;
844
+ nodes.forEach(d => {
845
+ if (!isNodeVisible(d)) return;
846
+
847
+ ctx.beginPath();
848
+ const isHover = window.hoverNode && window.hoverNode.id === d.id;
849
+ const isFocus = focusNode && focusNode.id === d.id;
850
+
851
+ let r = isFocus ? 25 : (d.centrality ? Math.max(3, Math.sqrt(d.centrality) * 3) : 5);
852
+ if (isHover) r += 2; // Slight enlarge on hover
853
+
854
+ ctx.arc(d.x, d.y, r, 0, 2 * Math.PI);
855
+
856
+ // Color
857
+ if (isFocus) {
858
+ ctx.fillStyle = "#ffd700";
859
+ } else if (isHover) {
860
+ ctx.fillStyle = "#ffaa00";
861
+ } else {
862
+ const mode = document.querySelector('input[name="colorMode"]:checked').value;
863
+ if (mode === 'cluster') ctx.fillStyle = colorScaleCluster(d.clusterId || 'unknown');
864
+ else ctx.fillStyle = colorScaleDegree(getDegree(d));
865
+ }
866
+
867
+ ctx.fill();
868
+ ctx.strokeStyle = "#fff";
869
+ ctx.lineWidth = 1.5;
870
+ ctx.stroke();
871
+
872
+ // Label
873
+ // Show if Focus, Hover, or Zoomed in
874
+ if (isFocus || isHover || currentTransform.k > 1.2) {
875
+ ctx.fillStyle = "#ccc";
876
+ ctx.font = isFocus ? "bold 16px Sans-Serif" : "10px Sans-Serif";
877
+ ctx.fillText(d.label, d.x + 8, d.y + 4);
878
+ }
879
+ });
880
+
881
+ ctx.restore();
882
+ }
883
+
884
+ simulation.on("tick", ticked);
885
+
886
+ // Renderer Toggle
887
+ document.querySelectorAll('input[name="rendererMode"]').forEach(radio => {
888
+ radio.addEventListener('change', (e) => {
889
+ const mode = e.target.value;
890
+ if (mode === 'canvas') {
891
+ document.querySelector('#graph-container svg').style.display = 'none';
892
+ canvas.style.display = 'block';
893
+ ticked();
894
+ } else {
895
+ document.querySelector('#graph-container svg').style.display = 'block';
896
+ canvas.style.display = 'none';
897
+ // Sync zoom state
898
+ g.attr("transform", currentTransform);
899
+ ticked();
900
+ }
901
+ });
902
+ });
903
+
904
+ // Controls & Filtering
905
+ const controls = {
906
+ minDegree: document.getElementById('min-degree-slider'),
907
+ showOrphans: document.getElementById('show-orphans'),
908
+ search: document.getElementById('search-input'),
909
+ export: document.getElementById('export-btn')
910
+ };
911
+
912
+ controls.minDegree.addEventListener('input', updateVisibility);
913
+ controls.showOrphans.addEventListener('change', updateVisibility);
914
+ controls.search.addEventListener('input', updateVisibility);
915
+ controls.export.addEventListener('click', exportSVG);
916
+
917
+ // Label Opacity Control
918
+ const labelOpacitySlider = document.getElementById('label-opacity-slider');
919
+ const labelOpacityVal = document.getElementById('label-opacity-val');
920
+
921
+ if (labelOpacitySlider && labelOpacityVal) {
922
+ labelOpacitySlider.addEventListener('input', (e) => {
923
+ const val = e.target.value;
924
+ labelOpacityVal.innerText = val + '%';
925
+ texts.style("opacity", val / 100);
926
+ });
927
+ }
928
+
929
+ function isNodeVisible(d) {
930
+ if (focusNode) {
931
+ // In Focus Mode, visibility is controlled by the enterFocusMode logic setting classes or explicit styles.
932
+ // However, updateVisibility() is called by mouseout and controls.
933
+ // We should respect the 'focus-visible' flag if we use one, OR check against the focus set.
934
+ // To keep it simple and robust: If focusNode is set, we let enterFocusMode handle opacity.
935
+ // But wait, updateVisibility resets opacity.
936
+ // So we need logic here:
937
+ if (d.id === focusNode.id) return true;
938
+ if (d.isFocusVisible) return true; // We will tag nodes in enterFocusMode
939
+ return false;
940
+ }
941
+
942
+ const minDegree = parseInt(controls.minDegree.value);
943
+ const showOrphans = controls.showOrphans.checked;
944
+ const term = controls.search.value.toLowerCase();
945
+
946
+ const degree = d.inDegree + d.outDegree;
947
+ const matchesDegree = degree >= minDegree;
948
+ const isOrphan = degree === 0;
949
+ const allowedOrphan = !isOrphan || showOrphans;
950
+ const matchesSearch = !term || d.label.toLowerCase().includes(term);
951
+
952
+ // Check Cluster Filter
953
+ const matchesCluster = activeClusterFilter === 'all' || (d.clusterId === activeClusterFilter);
954
+
955
+ return matchesDegree && allowedOrphan && matchesSearch && matchesCluster;
956
+ }
957
+
958
+ function updateVisibility() {
959
+ const minVal = controls.minDegree.value;
960
+ document.getElementById('min-degree-val').innerText = minVal;
961
+
962
+ node.style("opacity", d => isNodeVisible(d) ? 1 : 0.1)
963
+ .style("pointer-events", d => isNodeVisible(d) ? "all" : "none");
964
+
965
+ link.style("opacity", d => {
966
+ // If in Focus Mode, show connections to focus node
967
+ if (focusNode) {
968
+ const isConnected = d.source.id === focusNode.id || d.target.id === focusNode.id;
969
+ // Also show edges between visible nodes in focus mode?
970
+ // The requirement says "Context Filtering: Show only direct neighbors".
971
+ // So edges between visible nodes should be fine.
972
+ const sourceVis = isNodeVisible(d.source);
973
+ const targetVis = isNodeVisible(d.target);
974
+ return (sourceVis && targetVis) ? 0.6 : 0;
975
+ }
976
+
977
+ // Default Mode: Hide edges (0 opacity) to reduce clutter, unless hover handles it.
978
+ // Hover logic in 'mouseover' sets opacity to 1.
979
+ // Here we set the "base" state.
980
+ return 0;
981
+ });
982
+ }
983
+
984
+ function exportSVG() {
985
+ const svgEl = document.querySelector("#graph-container svg");
986
+
987
+ // 1. Clone the SVG to manipulate it without affecting the UI
988
+ const clone = svgEl.cloneNode(true);
989
+
990
+ // 2. Add Background Rect
991
+ const bgRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
992
+ bgRect.setAttribute("width", "100%");
993
+ bgRect.setAttribute("height", "100%");
994
+ bgRect.setAttribute("fill", "#1e1e1e"); // Match body background
995
+ clone.insertBefore(bgRect, clone.firstChild);
996
+
997
+ // 3. Inline Computed Styles for Nodes and Links
998
+ // We need to match elements in clone with original to get computed styles
999
+ const originalNodes = svgEl.querySelectorAll('.node circle, .node text');
1000
+ const cloneNodes = clone.querySelectorAll('.node circle, .node text');
1001
+
1002
+ originalNodes.forEach((orig, i) => {
1003
+ const cl = cloneNodes[i];
1004
+ const style = window.getComputedStyle(orig);
1005
+ cl.setAttribute("fill", style.fill);
1006
+ cl.setAttribute("stroke", style.stroke);
1007
+ cl.setAttribute("stroke-width", style.strokeWidth);
1008
+ cl.setAttribute("opacity", style.opacity);
1009
+ cl.setAttribute("font-size", style.fontSize);
1010
+ cl.setAttribute("font-family", style.fontFamily);
1011
+ });
1012
+
1013
+ const originalLinks = svgEl.querySelectorAll('.link');
1014
+ const cloneLinks = clone.querySelectorAll('.link');
1015
+
1016
+ originalLinks.forEach((orig, i) => {
1017
+ const cl = cloneLinks[i];
1018
+ const style = window.getComputedStyle(orig);
1019
+ cl.setAttribute("stroke", style.stroke);
1020
+ cl.setAttribute("stroke-width", style.strokeWidth);
1021
+ cl.setAttribute("stroke-opacity", style.strokeOpacity);
1022
+ cl.setAttribute("fill", "none"); // Links shouldn't have fill
1023
+ });
1024
+
1025
+ // 4. Serialize
1026
+ const serializer = new XMLSerializer();
1027
+ let source = serializer.serializeToString(clone);
1028
+
1029
+ // Add namespaces if missing
1030
+ if(!source.match(/^<svg[^>]+xmlns="http\:\/\/www\.w3\.org\/2000\/svg"/)){
1031
+ source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
1032
+ }
1033
+ if(!source.match(/^<svg[^>]+\"http\:\/\/www\.w3\.org\/1999\/xlink"/)){
1034
+ source = source.replace(/^<svg/, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
1035
+ }
1036
+
1037
+ const preamble = '<?xml version="1.0" standalone="no"?>\r\n';
1038
+ const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(preamble + source);
1039
+
1040
+ const downloadLink = document.createElement("a");
1041
+ downloadLink.href = url;
1042
+ downloadLink.download = "note_connection_graph.svg";
1043
+ document.body.appendChild(downloadLink);
1044
+ downloadLink.click();
1045
+ document.body.removeChild(downloadLink);
1046
+ }
1047
+
1048
+ // Save Layout
1049
+ document.getElementById('save-layout-btn').addEventListener('click', saveLayout);
1050
+
1051
+ function saveLayout() {
1052
+ const layoutData = nodes.map(n => ({
1053
+ id: n.id,
1054
+ x: Math.round(n.x),
1055
+ y: Math.round(n.y)
1056
+ }));
1057
+
1058
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(layoutData, null, 2));
1059
+ const downloadAnchorNode = document.createElement('a');
1060
+ downloadAnchorNode.setAttribute("href", dataStr);
1061
+ downloadAnchorNode.setAttribute("download", "layout.json");
1062
+ document.body.appendChild(downloadAnchorNode);
1063
+ downloadAnchorNode.click();
1064
+ downloadAnchorNode.remove();
1065
+ }
1066
+
1067
+ // Drag functions
1068
+ function dragstarted(event, d) {
1069
+ if (!event.active) simulation.alphaTarget(0.3).restart();
1070
+ d.fx = d.x;
1071
+ d.fy = d.y;
1072
+ }
1073
+
1074
+ function dragged(event, d) {
1075
+ d.fx = event.x;
1076
+ d.fy = event.y;
1077
+ }
1078
+
1079
+ function dragended(event, d) {
1080
+ if (!event.active) simulation.alphaTarget(0);
1081
+
1082
+ // In Focus Mode, nodes have fixed positions (fx, fy) set by the layout.
1083
+ // We want to allow manual adjustment (dragging) without them snapping back or drifting.
1084
+ // So if in Focus Mode, we simply RETAIN the fx/fy set during drag.
1085
+ // If NOT in Focus Mode (Force Layout), we release them to the simulation.
1086
+
1087
+ // v0.9.0: Also check Freeze Layout. If frozen, we treat it like Focus Mode (manual placement).
1088
+ const isFrozen = document.getElementById('freeze-layout').checked;
1089
+
1090
+ if (!focusNode && !isFrozen) {
1091
+ d.fx = null;
1092
+ d.fy = null;
1093
+ }
1094
+ }
1095
+
1096
+ // Focus Mode Logic
1097
+ document.getElementById('btn-exit-focus').addEventListener('click', exitFocusMode);
1098
+ document.getElementById('focus-spacing-slider').addEventListener('input', () => {
1099
+ if (focusNode) enterFocusMode(focusNode); // Re-calculate layout
1100
+ });
1101
+ document.getElementById('focus-h-spacing-slider').addEventListener('input', () => {
1102
+ if (focusNode) enterFocusMode(focusNode); // Re-calculate layout
1103
+ });
1104
+
1105
+ // Wire up click event to nodes
1106
+ // We need to re-bind the click event or add it to the existing selection
1107
+ // Since 'node' is a selection of groups 'g', we can add it.
1108
+ // Note: We used 'click' for drill-down in Cluster Mode.
1109
+ // In Node Mode, we want Focus Mode or Reader.
1110
+ node.on("click", (event, d) => {
1111
+ // If in Cluster Mode, ignore (handled by updateViewMode logic)
1112
+ const viewMode = document.querySelector('input[name="viewMode"]:checked').value;
1113
+ if (viewMode === 'nodes') {
1114
+ if (focusNode && focusNode.id === d.id) {
1115
+ // Clicked on ALREADY focused node -> Open Reader
1116
+ if (window.reader) window.reader.open(d);
1117
+ } else {
1118
+ // Enter Focus Mode
1119
+ enterFocusMode(d);
1120
+ }
1121
+ event.stopPropagation();
1122
+ }
1123
+ });
1124
+
1125
+ function enterFocusMode(focusD) {
1126
+ // If we re-enter (e.g. slider change), we don't return early unless it's strictly same state
1127
+ // But here we want to update positions, so we proceed.
1128
+
1129
+ // RESET ALL NODES first to prevent accumulation of visible nodes
1130
+ nodes.forEach(n => {
1131
+ n.isFocusVisible = false;
1132
+ // Optional: Reset fx/fy for cleanliness, but important one is visibility flag
1133
+ // We generally want to release nodes that are no longer part of the focus set
1134
+ n.fx = null;
1135
+ n.fy = null;
1136
+ n._labelDy = null;
1137
+ });
1138
+
1139
+ focusNode = focusD;
1140
+
1141
+ // 1. UI Updates
1142
+ document.getElementById('focus-exit-btn').style.display = 'flex';
1143
+ document.getElementById('focus-node-name').innerText = focusD.label;
1144
+ document.getElementById('controls').style.opacity = '0.3'; // Dim controls
1145
+ document.getElementById('controls').style.pointerEvents = 'none'; // Disable controls
1146
+
1147
+ // 2. Identify Nodes
1148
+ const superiors = []; // Outgoing: Focus -> Target (Superior)
1149
+ const subordinates = []; // Incoming: Source -> Focus (Subordinate)
1150
+
1151
+ links.forEach(l => {
1152
+ if (l.source.id === focusD.id) superiors.push(l.target);
1153
+ if (l.target.id === focusD.id) subordinates.push(l.source);
1154
+ });
1155
+
1156
+ const uniqueSup = [...new Set(superiors)];
1157
+ const uniqueSub = [...new Set(subordinates)];
1158
+
1159
+ // 3. Intra-layer Sorting & Scoring
1160
+ const getFocusScore = (n) => {
1161
+ const edge = links.find(l =>
1162
+ (l.source.id === focusD.id && l.target.id === n.id) ||
1163
+ (l.target.id === focusD.id && l.source.id === n.id)
1164
+ );
1165
+ const weight = edge ? (edge.weight || 0.5) : 0.5;
1166
+ const degreeRatio = (n.outDegree || 0) / ((n.inDegree || 0) + 1);
1167
+ const normRatio = Math.min(degreeRatio, 5) / 5;
1168
+ return (weight * 0.7) + (normRatio * 0.3);
1169
+ };
1170
+
1171
+ uniqueSup.forEach(n => n._focusScore = getFocusScore(n));
1172
+ uniqueSub.forEach(n => n._focusScore = getFocusScore(n));
1173
+
1174
+ const sortFn = (a, b) => b._focusScore - a._focusScore;
1175
+ uniqueSup.sort(sortFn);
1176
+ uniqueSub.sort(sortFn);
1177
+
1178
+ // 4. Layout Calculation
1179
+ const cx = width / 2;
1180
+ const cy = height / 2;
1181
+ // Get spacing from slider
1182
+ const layerGap = parseInt(document.getElementById('focus-spacing-slider').value) || 250;
1183
+ const hSpacing = parseInt(document.getElementById('focus-h-spacing-slider').value) || 80;
1184
+
1185
+ const spreadNodes = (nodeList, baselineY) => {
1186
+ const count = nodeList.length;
1187
+ if (count === 0) return;
1188
+
1189
+ // Use H-Spacing slider to control width
1190
+ // const spreadWidth = Math.min(width * 0.9, Math.max(count * 80, 200));
1191
+ // New Logic: Fixed spacing from slider * count
1192
+ // Center the group
1193
+
1194
+ const totalWidth = (count - 1) * hSpacing;
1195
+ const startX = cx - totalWidth / 2;
1196
+
1197
+ nodeList.forEach((n, i) => {
1198
+ n.fx = count === 1 ? cx : startX + i * hSpacing;
1199
+
1200
+ // Relative Height & Staggered Labels
1201
+ const stagger = (i % 2 === 0 ? -1 : 1) * 20;
1202
+ const criteriaOffset = (n._focusScore * 20);
1203
+ const totalOffset = stagger + criteriaOffset;
1204
+
1205
+ n.fy = baselineY + totalOffset;
1206
+ n.isFocusVisible = true;
1207
+
1208
+ if (n.fy < baselineY) n._labelDy = -15; // Above
1209
+ else n._labelDy = 25; // Below
1210
+ });
1211
+ };
1212
+
1213
+ // Focus Node
1214
+ focusD.fx = cx;
1215
+ focusD.fy = cy;
1216
+ focusD.isFocusVisible = true;
1217
+ focusD._labelDy = 35;
1218
+
1219
+ spreadNodes(uniqueSup, cy - layerGap);
1220
+ spreadNodes(uniqueSub, cy + layerGap);
1221
+
1222
+ // Associated Nodes
1223
+ const associated = [];
1224
+ links.forEach(l => {
1225
+ if ((l.source.id === focusD.id || l.target.id === focusD.id) && l.weight > 0.6) {
1226
+ const other = l.source.id === focusD.id ? l.target : l.source;
1227
+ if (!uniqueSup.includes(other) && !uniqueSub.includes(other)) {
1228
+ associated.push(other);
1229
+ }
1230
+ }
1231
+ });
1232
+
1233
+ if (associated.length > 0) {
1234
+ const left = [];
1235
+ const right = [];
1236
+ associated.forEach((n, i) => {
1237
+ n.isFocusVisible = true;
1238
+ if (i % 2 === 0) left.push(n); else right.push(n);
1239
+ });
1240
+
1241
+ const sideGap = 200;
1242
+ const placeSide = (list, dir) => {
1243
+ list.forEach((n, i) => {
1244
+ n.fx = cx + (dir * (sideGap + 100 + (i * 60)));
1245
+ n.fy = cy + (i % 2 === 0 ? -20 : 20);
1246
+ n._labelDy = 25;
1247
+ });
1248
+ };
1249
+ placeSide(left, -1);
1250
+ placeSide(right, 1);
1251
+ }
1252
+
1253
+ // 5. Apply Updates
1254
+ simulation.stop();
1255
+ link.style("display", "none");
1256
+ updateVisibility();
1257
+
1258
+ node.each(function(d) {
1259
+ if (isNodeVisible(d)) {
1260
+ const el = d3.select(this);
1261
+ el.transition().duration(750)
1262
+ .attr("transform", `translate(${d.fx},${d.fy})`);
1263
+
1264
+ el.select("text").transition().duration(750)
1265
+ .attr("dy", d._labelDy ? d._labelDy : ".35em");
1266
+
1267
+ if (d.id === focusD.id) {
1268
+ el.select("circle").transition().duration(750)
1269
+ .attr("r", 25).attr("fill", "#ffd700").attr("stroke", "#fff").attr("stroke-width", "3px");
1270
+ el.select("text").transition().duration(750)
1271
+ .attr("font-size", "16px").attr("font-weight", "bold").attr("fill", "#fff");
1272
+ } else {
1273
+ const isSup = uniqueSup.includes(d);
1274
+ const isSub = uniqueSub.includes(d);
1275
+ const color = isSup ? "#4ecdc4" : (isSub ? "#ff6b6b" : "#aaa");
1276
+ el.select("circle").transition().duration(750)
1277
+ .attr("r", 8).attr("fill", color);
1278
+ el.select("text").transition().duration(750)
1279
+ .attr("font-size", "10px").attr("font-weight", "normal").attr("fill", "#ccc");
1280
+ }
1281
+ } else {
1282
+ d.fx = null; d.fy = null; d.isFocusVisible = false; d._labelDy = null;
1283
+ }
1284
+ });
1285
+ simulation.alpha(0.1).restart();
1286
+ ticked(); // Force render update (Canvas)
1287
+ }
1288
+
1289
+
1290
+
1291
+ function exitFocusMode() {
1292
+
1293
+ focusNode = null;
1294
+
1295
+ document.getElementById('focus-exit-btn').style.display = 'none';
1296
+
1297
+ document.getElementById('controls').style.opacity = '1';
1298
+
1299
+ document.getElementById('controls').style.pointerEvents = 'all';
1300
+
1301
+ link.style("display", "block");
1302
+
1303
+
1304
+
1305
+ nodes.forEach(d => {
1306
+
1307
+ d.fx = null; d.fy = null; d.isFocusVisible = false; d._labelDy = null;
1308
+
1309
+ });
1310
+
1311
+
1312
+
1313
+ updateVisibility(); updateSize(); updateColor();
1314
+
1315
+
1316
+
1317
+ // Reset Texts
1318
+
1319
+ node.selectAll("text").transition().duration(500)
1320
+
1321
+ .attr("dy", ".35em") // Restore default
1322
+
1323
+ .attr("font-size", "10px").attr("font-weight", "normal").attr("fill", "#ccc");
1324
+
1325
+
1326
+
1327
+ node.selectAll("circle").transition().duration(500).attr("stroke-width", "1.5px");
1328
+
1329
+ simulation.alpha(1).restart();
1330
+
1331
+ }
1332
+
1333
+ // --- Settings Integration ---
1334
+
1335
+ function initSettingsUI() {
1336
+ const modal = document.getElementById('settings-modal');
1337
+ const openBtn = document.getElementById('btn-open-settings');
1338
+ const closeBtns = document.querySelectorAll('.modal-close');
1339
+ const resetBtn = document.getElementById('btn-reset-settings');
1340
+
1341
+ // Controls
1342
+ const inputs = {
1343
+ charge: document.getElementById('set-charge'),
1344
+ distance: document.getElementById('set-distance'),
1345
+ collision: document.getElementById('set-collision'),
1346
+ opacity: document.getElementById('set-opacity')
1347
+ };
1348
+
1349
+ const displays = {
1350
+ charge: document.getElementById('val-charge'),
1351
+ distance: document.getElementById('val-distance'),
1352
+ collision: document.getElementById('val-collision'),
1353
+ opacity: document.getElementById('val-opacity')
1354
+ };
1355
+
1356
+ // Reader Settings
1357
+ const inputReadingMode = document.getElementById('set-reading-mode');
1358
+
1359
+ // Load initial values
1360
+ const updateUIFromSettings = (settings) => {
1361
+ inputs.charge.value = settings.physics.chargeStrength;
1362
+ displays.charge.innerText = settings.physics.chargeStrength;
1363
+
1364
+ inputs.distance.value = settings.physics.linkDistance;
1365
+ displays.distance.innerText = settings.physics.linkDistance;
1366
+
1367
+ inputs.collision.value = settings.physics.collisionRadius;
1368
+ displays.collision.innerText = settings.physics.collisionRadius;
1369
+
1370
+ inputs.opacity.value = settings.visuals.edgeOpacity;
1371
+ displays.opacity.innerText = settings.visuals.edgeOpacity;
1372
+
1373
+ if (settings.reading && settings.reading.mode) {
1374
+ inputReadingMode.value = settings.reading.mode;
1375
+ }
1376
+ };
1377
+
1378
+ updateUIFromSettings(settingsManager.settings);
1379
+
1380
+ // Event Listeners for Inputs
1381
+ inputs.charge.addEventListener('input', (e) => {
1382
+ const val = parseInt(e.target.value);
1383
+ settingsManager.set('physics', 'chargeStrength', val);
1384
+ displays.charge.innerText = val;
1385
+ });
1386
+
1387
+ inputs.distance.addEventListener('input', (e) => {
1388
+ const val = parseInt(e.target.value);
1389
+ settingsManager.set('physics', 'linkDistance', val);
1390
+ displays.distance.innerText = val;
1391
+ });
1392
+
1393
+ inputs.collision.addEventListener('input', (e) => {
1394
+ const val = parseInt(e.target.value);
1395
+ settingsManager.set('physics', 'collisionRadius', val);
1396
+ displays.collision.innerText = val;
1397
+ });
1398
+
1399
+ inputs.opacity.addEventListener('input', (e) => {
1400
+ const val = parseFloat(e.target.value);
1401
+ settingsManager.set('visuals', 'edgeOpacity', val);
1402
+ displays.opacity.innerText = val;
1403
+ });
1404
+
1405
+ inputReadingMode.addEventListener('change', (e) => {
1406
+ settingsManager.set('reading', 'mode', e.target.value);
1407
+ });
1408
+
1409
+ // Modal Actions
1410
+ openBtn.addEventListener('click', () => modal.style.display = 'flex');
1411
+ closeBtns.forEach(btn => btn.addEventListener('click', () => modal.style.display = 'none'));
1412
+
1413
+ // Close on click outside
1414
+ modal.addEventListener('click', (e) => {
1415
+ if (e.target === modal) modal.style.display = 'none';
1416
+ });
1417
+
1418
+ resetBtn.addEventListener('click', () => {
1419
+ settingsManager.reset();
1420
+ updateUIFromSettings(settingsManager.settings);
1421
+ });
1422
+
1423
+ // Subscribe to changes
1424
+ settingsManager.subscribe((settings) => {
1425
+ // Apply Physics
1426
+ if (!focusNode) { // Only apply physics updates if NOT in Focus Mode (which locks positions)
1427
+ simulation.force("charge").strength(settings.physics.chargeStrength);
1428
+ simulation.force("link").distance(settings.physics.linkDistance);
1429
+ simulation.force("collide").radius(settings.physics.collisionRadius);
1430
+ simulation.alpha(0.3).restart();
1431
+ }
1432
+
1433
+ // Apply Visuals
1434
+ g.selectAll(".link").style("stroke-opacity", settings.visuals.edgeOpacity);
1435
+ });
1436
+ }
1437
+
1438
+ // Initialize Settings
1439
+ if (window.settingsManager) {
1440
+ initSettingsUI();
1441
+ // Apply initial settings immediately
1442
+ const s = settingsManager.settings;
1443
+ simulation.force("charge").strength(s.physics.chargeStrength);
1444
+ simulation.force("link").distance(s.physics.linkDistance);
1445
+ simulation.force("collide").radius(s.physics.collisionRadius);
1446
+ g.selectAll(".link").style("stroke-opacity", s.visuals.edgeOpacity);
1447
+ }