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.
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/backend/CommunityDetection.js +58 -0
- package/dist/backend/FileLoader.js +110 -0
- package/dist/backend/GraphBuilder.js +347 -0
- package/dist/backend/GraphMetrics.js +70 -0
- package/dist/backend/algorithms/CycleDetection.js +63 -0
- package/dist/backend/algorithms/HybridEngine.js +70 -0
- package/dist/backend/algorithms/StatisticalAnalyzer.js +123 -0
- package/dist/backend/algorithms/TopologicalSort.js +69 -0
- package/dist/backend/algorithms/VectorSpace.js +87 -0
- package/dist/backend/build_dag.js +164 -0
- package/dist/backend/config.js +17 -0
- package/dist/backend/graph.js +108 -0
- package/dist/backend/main.js +67 -0
- package/dist/backend/parser.js +94 -0
- package/dist/backend/test_robustness/test_hybrid.js +60 -0
- package/dist/backend/test_robustness/test_statistics.js +58 -0
- package/dist/backend/test_robustness/test_vector.js +54 -0
- package/dist/backend/test_robustness.js +113 -0
- package/dist/backend/types.js +3 -0
- package/dist/backend/utils/frontmatterParser.js +121 -0
- package/dist/backend/utils/stringUtils.js +66 -0
- package/dist/backend/workers/keywordMatchWorker.js +22 -0
- package/dist/core/Graph.js +121 -0
- package/dist/core/Graph.test.js +37 -0
- package/dist/core/types.js +2 -0
- package/dist/frontend/analysis.js +356 -0
- package/dist/frontend/app.js +1447 -0
- package/dist/frontend/data.js +8356 -0
- package/dist/frontend/graph_data.json +8356 -0
- package/dist/frontend/index.html +279 -0
- package/dist/frontend/reader.js +177 -0
- package/dist/frontend/settings.js +84 -0
- package/dist/frontend/source_manager.js +61 -0
- package/dist/frontend/styles.css +577 -0
- package/dist/frontend/styles_analysis.css +145 -0
- package/dist/index.js +121 -0
- package/dist/server.js +149 -0
- 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
|
+
}
|