1use std::collections::{HashMap, HashSet};
4use std::fmt::Write as FmtWrite;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use graphify_core::confidence::Confidence;
9use graphify_core::graph::KnowledgeGraph;
10use tracing::{info, warn};
11
12const COMMUNITY_COLORS: &[&str] = &[
13 "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F", "#EDC948", "#B07AA1", "#FF9DA7",
14 "#9C755F", "#BAB0AC",
15];
16
17const PRUNE_THRESHOLD: usize = 2000;
19const MAX_VIS_NODES: usize = 2000;
21
22pub fn export_html(
27 graph: &KnowledgeGraph,
28 communities: &HashMap<usize, Vec<String>>,
29 community_labels: &HashMap<usize, String>,
30 output_dir: &Path,
31) -> anyhow::Result<PathBuf> {
32 let total_nodes = graph.node_count();
33 let total_edges = graph.edge_count();
34
35 let (included_nodes, pruned) = if total_nodes > PRUNE_THRESHOLD {
37 warn!(
38 total_nodes,
39 threshold = PRUNE_THRESHOLD,
40 "graph too large for interactive viz, pruning to top {} nodes",
41 MAX_VIS_NODES
42 );
43 (prune_nodes(graph, communities, MAX_VIS_NODES), true)
44 } else {
45 (
46 graph.node_ids().into_iter().collect::<HashSet<String>>(),
47 false,
48 )
49 };
50
51 let mut node_community: HashMap<&str, usize> = HashMap::new();
53 for (&cid, members) in communities {
54 for nid in members {
55 node_community.insert(nid.as_str(), cid);
56 }
57 }
58
59 let mut vis_nodes = String::from("[");
61 let mut first = true;
62 for node in graph.nodes() {
63 if !included_nodes.contains(&node.id) {
64 continue;
65 }
66 if !first {
67 vis_nodes.push(',');
68 }
69 first = false;
70 let cid = node
71 .community
72 .or_else(|| node_community.get(node.id.as_str()).copied());
73 let color = cid
74 .map(|c| COMMUNITY_COLORS[c % COMMUNITY_COLORS.len()])
75 .unwrap_or("#888888");
76 let degree = graph.degree(&node.id);
77 let size = 8.0 + (degree as f64).sqrt() * 4.0;
79 let label_escaped = escape_js(&node.label);
80 let title_escaped = escape_js(&format!(
81 "{} ({})\nFile: {}\nType: {:?}\nDegree: {}",
82 node.label, node.id, node.source_file, node.node_type, degree
83 ));
84 write!(
85 vis_nodes,
86 r#"{{id:"{}",label:"{}",title:"{}",color:"{}",community:{},size:{:.1}}}"#,
87 escape_js(&node.id),
88 label_escaped,
89 title_escaped,
90 color,
91 cid.unwrap_or(0),
92 size,
93 )
94 .unwrap();
95 }
96 vis_nodes.push(']');
97
98 let mut vis_edges = String::from("[");
100 first = true;
101 for edge in graph.edges() {
102 if !included_nodes.contains(&edge.source) || !included_nodes.contains(&edge.target) {
103 continue;
104 }
105 if !first {
106 vis_edges.push(',');
107 }
108 first = false;
109 let dashes = match edge.confidence {
110 Confidence::Extracted => "false",
111 Confidence::Inferred | Confidence::Ambiguous => "true",
112 };
113 let width = 1.0 + edge.confidence_score * 2.0;
114 let title_escaped = escape_js(&format!(
115 "{}: {} → {}\nConfidence: {:?} ({:.2})\nFile: {}",
116 edge.relation,
117 edge.source,
118 edge.target,
119 edge.confidence,
120 edge.confidence_score,
121 edge.source_file
122 ));
123 write!(
124 vis_edges,
125 r#"{{from:"{}",to:"{}",label:"{}",title:"{}",dashes:{},width:{:.1}}}"#,
126 escape_js(&edge.source),
127 escape_js(&edge.target),
128 escape_js(&edge.relation),
129 title_escaped,
130 dashes,
131 width,
132 )
133 .unwrap();
134 }
135 vis_edges.push(']');
136
137 let mut legend_html = String::new();
139 for (&cid, label) in community_labels {
140 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
141 write!(
142 legend_html,
143 r#"<div class="legend-item"><span class="legend-dot" style="background:{}"></span>{}</div>"#,
144 color,
145 escape_html(label),
146 )
147 .unwrap();
148 }
149
150 let mut hyperedge_html = String::new();
152 for he in &graph.hyperedges {
153 write!(
154 hyperedge_html,
155 "<li><b>{}</b>: {} ({})</li>",
156 escape_html(&he.relation),
157 escape_html(&he.label),
158 he.nodes.join(", "),
159 )
160 .unwrap();
161 }
162
163 let prune_banner = if pruned {
165 format!(
166 r#"<div id="prune-banner">Showing top {} of {} nodes ({} edges total). Only highest-degree nodes and community representatives are displayed.</div>"#,
167 included_nodes.len(),
168 total_nodes,
169 total_edges,
170 )
171 } else {
172 String::new()
173 };
174
175 let is_large = included_nodes.len() > 500;
176 let html = build_html_template(
177 &vis_nodes,
178 &vis_edges,
179 &legend_html,
180 &hyperedge_html,
181 &prune_banner,
182 is_large,
183 );
184
185 fs::create_dir_all(output_dir)?;
186 let path = output_dir.join("graph.html");
187 fs::write(&path, &html)?;
188 info!(path = %path.display(), nodes = included_nodes.len(), "exported interactive HTML visualization");
189 Ok(path)
190}
191
192fn prune_nodes(
199 graph: &KnowledgeGraph,
200 communities: &HashMap<usize, Vec<String>>,
201 max_nodes: usize,
202) -> HashSet<String> {
203 let mut included: HashSet<String> = HashSet::new();
204
205 let mut by_degree: Vec<(String, usize)> = graph
207 .node_ids()
208 .into_iter()
209 .map(|id| {
210 let deg = graph.degree(&id);
211 (id, deg)
212 })
213 .collect();
214 by_degree.sort_by(|a, b| b.1.cmp(&a.1));
215
216 let community_slots = communities.len().min(max_nodes / 4);
218 let degree_slots = max_nodes.saturating_sub(community_slots);
219
220 for (id, _) in by_degree.iter().take(degree_slots) {
221 included.insert(id.clone());
222 }
223
224 for members in communities.values() {
226 if included.len() >= max_nodes {
227 break;
228 }
229 let best = members.iter().max_by_key(|id| graph.degree(id)).cloned();
230 if let Some(id) = best {
231 included.insert(id);
232 }
233 }
234
235 included
236}
237
238fn escape_js(s: &str) -> String {
239 s.replace('\\', "\\\\")
240 .replace('"', "\\\"")
241 .replace('\n', "\\n")
242 .replace('\r', "\\r")
243 .replace('\t', "\\t")
244}
245
246fn escape_html(s: &str) -> String {
247 s.replace('&', "&")
248 .replace('<', "<")
249 .replace('>', ">")
250 .replace('"', """)
251}
252
253fn build_html_template(
254 vis_nodes: &str,
255 vis_edges: &str,
256 legend_html: &str,
257 hyperedge_html: &str,
258 prune_banner: &str,
259 is_large: bool,
260) -> String {
261 let physics_config = if is_large {
263 r#"
264 solver: 'barnesHut',
265 barnesHut: {
266 gravitationalConstant: -8000,
267 centralGravity: 0.3,
268 springLength: 95,
269 springConstant: 0.04,
270 damping: 0.09,
271 avoidOverlap: 0.2
272 },
273 stabilization: { iterations: 150, fit: true },
274 adaptiveTimestep: true"#
275 } else {
276 r#"
277 solver: 'forceAtlas2Based',
278 forceAtlas2Based: {
279 gravitationalConstant: -50,
280 centralGravity: 0.01,
281 springLength: 120,
282 springConstant: 0.08,
283 damping: 0.4,
284 avoidOverlap: 0.5
285 },
286 stabilization: { iterations: 200 }"#
287 };
288
289 let edge_font_size = if is_large { 0 } else { 10 };
291 let node_font_size = if is_large { 10 } else { 12 };
292
293 format!(
294 r##"<!DOCTYPE html>
295<html lang="en">
296<head>
297<meta charset="utf-8">
298<meta name="viewport" content="width=device-width, initial-scale=1">
299<title>Knowledge Graph Visualization</title>
300<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
301<style>
302* {{ margin: 0; padding: 0; box-sizing: border-box; }}
303body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
304#sidebar {{ width: 320px; min-width: 320px; background: #1a1a2e; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; border-right: 1px solid #2a2a4a; }}
305#sidebar h2 {{ font-size: 18px; color: #76B7B2; margin-bottom: 4px; }}
306#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 4px; }}
307#search {{ width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: #0f0f1a; color: #e0e0e0; font-size: 14px; }}
308#search:focus {{ outline: none; border-color: #4E79A7; }}
309#info-panel {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; min-height: 120px; }}
310#info-panel .prop {{ color: #9ca3af; }}
311#info-panel .val {{ color: #e0e0e0; }}
312.legend-item {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 2px 0; }}
313.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
314#graph-container {{ flex: 1; position: relative; }}
315#hyperedges {{ font-size: 13px; }}
316#hyperedges ul {{ padding-left: 18px; }}
317#hyperedges li {{ margin-bottom: 4px; }}
318#prune-banner {{ background: #2a1a00; border: 1px solid #F28E2B; border-radius: 6px; padding: 8px 12px; font-size: 12px; color: #F28E2B; }}
319#loading {{ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 16px; color: #76B7B2; z-index: 10; }}
320</style>
321</head>
322<body>
323<div id="sidebar">
324 <div>
325 <h2>🧠 Knowledge Graph</h2>
326 <p style="font-size:12px;color:#666;">Click a node to inspect · Scroll to zoom</p>
327 </div>
328 {prune_banner}
329 <input id="search" type="text" placeholder="Search nodes…" />
330 <div>
331 <h3>Node Info</h3>
332 <div id="info-panel"><i style="color:#666">Click a node to see details</i></div>
333 </div>
334 <div>
335 <h3>Communities</h3>
336 <div id="legend">{legend}</div>
337 </div>
338 <div id="hyperedges">
339 <h3>Hyperedges</h3>
340 <ul>{hyperedges}</ul>
341 </div>
342</div>
343<div id="graph-container">
344 <div id="loading">⏳ Laying out graph…</div>
345</div>
346<script>
347(function() {{
348 var nodesData = {nodes};
349 var edgesData = {edges};
350
351 var container = document.getElementById('graph-container');
352 var loading = document.getElementById('loading');
353 var nodes = new vis.DataSet(nodesData);
354 var edges = new vis.DataSet(edgesData);
355
356 var options = {{
357 physics: {{{physics}}},
358 nodes: {{
359 shape: 'dot',
360 font: {{ color: '#e0e0e0', size: {node_font_size} }},
361 borderWidth: 2
362 }},
363 edges: {{
364 color: {{ color: '#4a4a6a', highlight: '#76B7B2', hover: '#76B7B2' }},
365 font: {{ color: '#888', size: {edge_font_size}, strokeWidth: 0 }},
366 arrows: {{ to: {{ enabled: false }} }},
367 smooth: {{ type: 'continuous' }}
368 }},
369 interaction: {{
370 hover: true,
371 tooltipDelay: 200,
372 zoomView: true,
373 dragView: true
374 }}
375 }};
376
377 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
378
379 // Hide loading and disable physics after stabilization
380 network.on('stabilizationIterationsDone', function() {{
381 loading.style.display = 'none';
382 network.setOptions({{ physics: {{ enabled: false }} }});
383 }});
384
385 // Fallback: hide loading after 10 seconds max
386 setTimeout(function() {{
387 loading.style.display = 'none';
388 }}, 10000);
389
390 // Click to inspect
391 network.on('click', function(params) {{
392 var panel = document.getElementById('info-panel');
393 if (params.nodes.length > 0) {{
394 var nodeId = params.nodes[0];
395 var node = nodes.get(nodeId);
396 if (node) {{
397 panel.innerHTML =
398 '<div><span class="prop">Label:</span> <span class="val">' + escapeHtml(node.label) + '</span></div>' +
399 '<div><span class="prop">ID:</span> <span class="val">' + escapeHtml(node.id) + '</span></div>' +
400 '<div><span class="prop">Community:</span> <span class="val">' + node.community + '</span></div>';
401 network.focus(nodeId, {{ scale: 1.2, animation: true }});
402 }}
403 }} else {{
404 panel.innerHTML = '<i style="color:#666">Click a node to see details</i>';
405 }}
406 }});
407
408 // Search (debounced, batch update)
409 var searchInput = document.getElementById('search');
410 var searchTimer = null;
411 searchInput.addEventListener('input', function() {{
412 clearTimeout(searchTimer);
413 searchTimer = setTimeout(function() {{
414 var term = searchInput.value.toLowerCase();
415 var updates = [];
416 nodes.forEach(function(n) {{
417 var match = !term || n.label.toLowerCase().includes(term) || n.id.toLowerCase().includes(term);
418 if (n.hidden !== !match) {{ updates.push({{ id: n.id, hidden: !match }}); }}
419 }});
420 if (updates.length > 0) {{ nodes.update(updates); }}
421 }}, 200);
422 }});
423
424 function escapeHtml(s) {{
425 var d = document.createElement('div');
426 d.textContent = s;
427 return d.innerHTML;
428 }}
429}})();
430</script>
431</body>
432</html>"##,
433 nodes = vis_nodes,
434 edges = vis_edges,
435 legend = legend_html,
436 hyperedges = hyperedge_html,
437 prune_banner = prune_banner,
438 physics = physics_config,
439 node_font_size = node_font_size,
440 edge_font_size = edge_font_size,
441 )
442}
443
444pub fn export_html_split(
458 graph: &KnowledgeGraph,
459 communities: &HashMap<usize, Vec<String>>,
460 community_labels: &HashMap<usize, String>,
461 output_dir: &Path,
462) -> anyhow::Result<PathBuf> {
463 let html_dir = output_dir.join("html");
464 fs::create_dir_all(&html_dir)?;
465
466 let mut node_community: HashMap<&str, usize> = HashMap::new();
468 for (&cid, members) in communities {
469 for nid in members {
470 node_community.insert(nid.as_str(), cid);
471 }
472 }
473
474 generate_overview(
476 &html_dir,
477 graph,
478 communities,
479 community_labels,
480 &node_community,
481 )?;
482
483 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
485 sorted_cids.sort();
486 for &cid in &sorted_cids {
487 let members = &communities[&cid];
488 let label = community_labels
489 .get(&cid)
490 .cloned()
491 .unwrap_or_else(|| format!("Community {}", cid));
492 generate_community_page(
493 &html_dir,
494 graph,
495 cid,
496 &label,
497 members,
498 community_labels,
499 &node_community,
500 )?;
501 }
502
503 info!(
504 path = %html_dir.display(),
505 communities = communities.len(),
506 "exported split HTML visualization"
507 );
508 Ok(html_dir)
509}
510
511fn generate_overview(
513 html_dir: &Path,
514 graph: &KnowledgeGraph,
515 communities: &HashMap<usize, Vec<String>>,
516 community_labels: &HashMap<usize, String>,
517 node_community: &HashMap<&str, usize>,
518) -> anyhow::Result<()> {
519 let mut vis_nodes = String::from("[");
521 let mut first = true;
522 for (&cid, members) in communities {
523 if !first {
524 vis_nodes.push(',');
525 }
526 first = false;
527 let label = community_labels
528 .get(&cid)
529 .cloned()
530 .unwrap_or_else(|| format!("Community {}", cid));
531 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
532 let size = 20.0 + (members.len() as f64).sqrt() * 5.0;
533 let title = format!(
534 "{} ({} nodes)\\nClick to view details",
535 label,
536 members.len()
537 );
538 write!(
539 vis_nodes,
540 r#"{{id:{cid},label:"{label} ({count})",title:"{title}",color:"{color}",size:{size:.1},url:"community_{cid}.html"}}"#,
541 cid = cid,
542 label = escape_js(&label),
543 count = members.len(),
544 title = escape_js(&title),
545 color = color,
546 size = size,
547 )
548 .unwrap();
549 }
550 vis_nodes.push(']');
551
552 let mut cross_edges: HashMap<(usize, usize), usize> = HashMap::new();
554 for edge in graph.edges() {
555 let src_cid = node_community.get(edge.source.as_str()).copied();
556 let tgt_cid = node_community.get(edge.target.as_str()).copied();
557 if let (Some(sc), Some(tc)) = (src_cid, tgt_cid)
558 && sc != tc
559 {
560 let key = if sc < tc { (sc, tc) } else { (tc, sc) };
561 *cross_edges.entry(key).or_default() += 1;
562 }
563 }
564
565 let mut vis_edges = String::from("[");
566 first = true;
567 for ((from, to), count) in &cross_edges {
568 if !first {
569 vis_edges.push(',');
570 }
571 first = false;
572 let width = 1.0 + (*count as f64).sqrt();
573 write!(
574 vis_edges,
575 r#"{{from:{from},to:{to},label:"{count}",width:{width:.1},title:"{count} cross-community edges"}}"#,
576 from = from,
577 to = to,
578 count = count,
579 width = width,
580 )
581 .unwrap();
582 }
583 vis_edges.push(']');
584
585 let mut nav_html = String::new();
587 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
588 sorted_cids.sort();
589 for cid in &sorted_cids {
590 let label = community_labels
591 .get(cid)
592 .cloned()
593 .unwrap_or_else(|| format!("Community {}", cid));
594 let color = COMMUNITY_COLORS[*cid % COMMUNITY_COLORS.len()];
595 let count = communities[cid].len();
596 write!(
597 nav_html,
598 r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count})</a>"#,
599 cid = cid,
600 color = color,
601 label = escape_html(&label),
602 count = count,
603 )
604 .unwrap();
605 }
606
607 let html = format!(
608 r##"<!DOCTYPE html>
609<html lang="en">
610<head>
611<meta charset="utf-8">
612<meta name="viewport" content="width=device-width, initial-scale=1">
613<title>Knowledge Graph — Overview</title>
614<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
615<style>
616* {{ margin: 0; padding: 0; box-sizing: border-box; }}
617body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
618#sidebar {{ width: 320px; min-width: 320px; background: #1a1a2e; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; border-right: 1px solid #2a2a4a; }}
619#sidebar h2 {{ font-size: 18px; color: #76B7B2; margin-bottom: 4px; }}
620#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
621.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
622.nav-link:hover {{ background: #2a2a4a; }}
623.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
624#graph-container {{ flex: 1; position: relative; }}
625#info {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; color: #9ca3af; }}
626</style>
627</head>
628<body>
629<div id="sidebar">
630 <div>
631 <h2>🧠 Overview</h2>
632 <p style="font-size:12px;color:#666;">Each node is a community. Click to view details.</p>
633 </div>
634 <div id="info">{node_count} nodes, {edge_count} edges, {community_count} communities</div>
635 <div>
636 <h3>Communities</h3>
637 {nav}
638 </div>
639</div>
640<div id="graph-container"></div>
641<script>
642(function() {{
643 var nodesData = {nodes};
644 var edgesData = {edges};
645 var container = document.getElementById('graph-container');
646 var nodes = new vis.DataSet(nodesData);
647 var edges = new vis.DataSet(edgesData);
648 var options = {{
649 physics: {{
650 solver: 'forceAtlas2Based',
651 forceAtlas2Based: {{ gravitationalConstant: -100, centralGravity: 0.01, springLength: 200, springConstant: 0.05, damping: 0.4 }},
652 stabilization: {{ iterations: 100 }}
653 }},
654 nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 14, multi: true }}, borderWidth: 2 }},
655 edges: {{ color: {{ color: '#4a4a6a' }}, font: {{ color: '#888', size: 12 }}, smooth: {{ type: 'continuous' }} }},
656 interaction: {{ hover: true, zoomView: true, dragView: true }}
657 }};
658 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
659 network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
660 network.on('doubleClick', function(params) {{
661 if (params.nodes.length > 0) {{
662 var node = nodes.get(params.nodes[0]);
663 if (node && node.url) {{ window.location.href = node.url; }}
664 }}
665 }});
666}})();
667</script>
668</body>
669</html>"##,
670 nodes = vis_nodes,
671 edges = vis_edges,
672 nav = nav_html,
673 node_count = graph.node_count(),
674 edge_count = graph.edge_count(),
675 community_count = communities.len(),
676 );
677
678 fs::write(html_dir.join("index.html"), &html)?;
679 Ok(())
680}
681
682fn generate_community_page(
684 html_dir: &Path,
685 graph: &KnowledgeGraph,
686 cid: usize,
687 label: &str,
688 members: &[String],
689 community_labels: &HashMap<usize, String>,
690 node_community: &HashMap<&str, usize>,
691) -> anyhow::Result<()> {
692 let member_set: HashSet<&str> = members.iter().map(|s| s.as_str()).collect();
693 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
694
695 let mut vis_nodes = String::from("[");
697 let mut first = true;
698 for node in graph.nodes() {
699 if !member_set.contains(node.id.as_str()) {
700 continue;
701 }
702 if !first {
703 vis_nodes.push(',');
704 }
705 first = false;
706 let degree = graph.degree(&node.id);
707 let size = 8.0 + (degree as f64).sqrt() * 4.0;
708 write!(
709 vis_nodes,
710 r#"{{id:"{}",label:"{}",title:"{}",color:"{}",size:{:.1}}}"#,
711 escape_js(&node.id),
712 escape_js(&node.label),
713 escape_js(&format!(
714 "{}\nType: {:?}\nFile: {}\nDegree: {}",
715 node.label, node.node_type, node.source_file, degree
716 )),
717 color,
718 size,
719 )
720 .unwrap();
721 }
722 vis_nodes.push(']');
723
724 let mut vis_edges = String::from("[");
726 first = true;
727 for edge in graph.edges() {
728 if !member_set.contains(edge.source.as_str()) || !member_set.contains(edge.target.as_str())
729 {
730 continue;
731 }
732 if !first {
733 vis_edges.push(',');
734 }
735 first = false;
736 let dashes = match edge.confidence {
737 Confidence::Extracted => "false",
738 _ => "true",
739 };
740 write!(
741 vis_edges,
742 r#"{{from:"{}",to:"{}",label:"{}",dashes:{},title:"{}"}}"#,
743 escape_js(&edge.source),
744 escape_js(&edge.target),
745 escape_js(&edge.relation),
746 dashes,
747 escape_js(&format!(
748 "{}: {} → {}\nConfidence: {:?}",
749 edge.relation, edge.source, edge.target, edge.confidence
750 )),
751 )
752 .unwrap();
753 }
754 vis_edges.push(']');
755
756 let mut external_links: HashMap<usize, usize> = HashMap::new();
758 for node_id in members {
759 for edge in graph.edges() {
760 let other = if edge.source == *node_id {
761 &edge.target
762 } else if edge.target == *node_id {
763 &edge.source
764 } else {
765 continue;
766 };
767 if let Some(&other_cid) = node_community.get(other.as_str())
768 && other_cid != cid
769 {
770 *external_links.entry(other_cid).or_default() += 1;
771 }
772 }
773 }
774
775 let mut nav_html = String::from(
776 r#"<a href="index.html" class="nav-link" style="font-weight:bold;">← Overview</a>"#,
777 );
778 let mut sorted_ext: Vec<(usize, usize)> = external_links.into_iter().collect();
779 sorted_ext.sort_by(|a, b| b.1.cmp(&a.1));
780 for (ext_cid, count) in &sorted_ext {
781 let ext_label = community_labels
782 .get(ext_cid)
783 .cloned()
784 .unwrap_or_else(|| format!("Community {}", ext_cid));
785 let ext_color = COMMUNITY_COLORS[*ext_cid % COMMUNITY_COLORS.len()];
786 write!(
787 nav_html,
788 r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count} links)</a>"#,
789 cid = ext_cid,
790 color = ext_color,
791 label = escape_html(&ext_label),
792 count = count,
793 )
794 .unwrap();
795 }
796
797 let is_large = members.len() > 500;
798 let physics = if is_large {
799 "solver:'barnesHut',barnesHut:{gravitationalConstant:-3000,springLength:95,damping:0.09},stabilization:{iterations:150}"
800 } else {
801 "solver:'forceAtlas2Based',forceAtlas2Based:{gravitationalConstant:-50,centralGravity:0.01,springLength:120,springConstant:0.08,damping:0.4,avoidOverlap:0.5},stabilization:{iterations:200}"
802 };
803 let edge_font = if is_large { 0 } else { 10 };
804
805 let html = format!(
806 r##"<!DOCTYPE html>
807<html lang="en">
808<head>
809<meta charset="utf-8">
810<meta name="viewport" content="width=device-width, initial-scale=1">
811<title>{title}</title>
812<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
813<style>
814* {{ margin: 0; padding: 0; box-sizing: border-box; }}
815body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
816#sidebar {{ width: 320px; min-width: 320px; background: #1a1a2e; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; border-right: 1px solid #2a2a4a; }}
817#sidebar h2 {{ font-size: 18px; color: {color}; margin-bottom: 4px; }}
818#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
819.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
820.nav-link:hover {{ background: #2a2a4a; }}
821.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
822#graph-container {{ flex: 1; position: relative; }}
823#search {{ width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: #0f0f1a; color: #e0e0e0; font-size: 14px; }}
824#search:focus {{ outline: none; border-color: {color}; }}
825#info-panel {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; min-height: 100px; }}
826#info-panel .prop {{ color: #9ca3af; }}
827#info-panel .val {{ color: #e0e0e0; }}
828</style>
829</head>
830<body>
831<div id="sidebar">
832 <div>
833 <h2>{label}</h2>
834 <p style="font-size:12px;color:#666;">{node_count} nodes · Community {cid}</p>
835 </div>
836 <input id="search" type="text" placeholder="Search nodes…" />
837 <div>
838 <h3>Node Info</h3>
839 <div id="info-panel"><i style="color:#666">Click a node to see details</i></div>
840 </div>
841 <div>
842 <h3>Navigation</h3>
843 {nav}
844 </div>
845</div>
846<div id="graph-container"></div>
847<script>
848(function() {{
849 var nodesData = {nodes};
850 var edgesData = {edges};
851 var container = document.getElementById('graph-container');
852 var nodes = new vis.DataSet(nodesData);
853 var edges = new vis.DataSet(edgesData);
854 var options = {{
855 physics: {{{physics}}},
856 nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 12 }}, borderWidth: 2 }},
857 edges: {{ color: {{ color: '#4a4a6a', highlight: '{color}', hover: '{color}' }}, font: {{ color: '#888', size: {edge_font} }}, arrows: {{ to: {{ enabled: false }} }}, smooth: {{ type: 'continuous' }} }},
858 interaction: {{ hover: true, tooltipDelay: 200, zoomView: true, dragView: true }}
859 }};
860 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
861 network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
862 network.on('click', function(params) {{
863 var panel = document.getElementById('info-panel');
864 if (params.nodes.length > 0) {{
865 var node = nodes.get(params.nodes[0]);
866 if (node) {{
867 panel.innerHTML = '<div><span class="prop">Label:</span> <span class="val">' + node.label + '</span></div><div><span class="prop">ID:</span> <span class="val">' + node.id + '</span></div>';
868 network.focus(params.nodes[0], {{ scale: 1.2, animation: true }});
869 }}
870 }}
871 }});
872 var searchEl = document.getElementById('search');
873 var sTimer = null;
874 searchEl.addEventListener('input', function() {{
875 clearTimeout(sTimer);
876 sTimer = setTimeout(function() {{
877 var term = searchEl.value.toLowerCase();
878 var updates = [];
879 nodes.forEach(function(n) {{
880 var h = term && !n.label.toLowerCase().includes(term);
881 if (n.hidden !== h) {{ updates.push({{ id: n.id, hidden: h }}); }}
882 }});
883 if (updates.length > 0) {{ nodes.update(updates); }}
884 }}, 200);
885 }});
886}})();
887</script>
888</body>
889</html>"##,
890 title = escape_html(&format!("{} — Community {}", label, cid)),
891 color = color,
892 label = escape_html(label),
893 cid = cid,
894 node_count = members.len(),
895 nodes = vis_nodes,
896 edges = vis_edges,
897 nav = nav_html,
898 physics = physics,
899 edge_font = edge_font,
900 );
901
902 fs::write(html_dir.join(format!("community_{}.html", cid)), &html)?;
903 Ok(())
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use graphify_core::confidence::Confidence;
910 use graphify_core::graph::KnowledgeGraph;
911 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
912
913 fn sample_graph() -> KnowledgeGraph {
914 let mut kg = KnowledgeGraph::new();
915 kg.add_node(GraphNode {
916 id: "a".into(),
917 label: "NodeA".into(),
918 source_file: "test.rs".into(),
919 source_location: None,
920 node_type: NodeType::Class,
921 community: Some(0),
922 extra: HashMap::new(),
923 })
924 .unwrap();
925 kg.add_node(GraphNode {
926 id: "b".into(),
927 label: "NodeB".into(),
928 source_file: "test.rs".into(),
929 source_location: None,
930 node_type: NodeType::Function,
931 community: Some(1),
932 extra: HashMap::new(),
933 })
934 .unwrap();
935 kg.add_edge(GraphEdge {
936 source: "a".into(),
937 target: "b".into(),
938 relation: "calls".into(),
939 confidence: Confidence::Inferred,
940 confidence_score: 0.7,
941 source_file: "test.rs".into(),
942 source_location: None,
943 weight: 1.0,
944 extra: HashMap::new(),
945 })
946 .unwrap();
947 kg
948 }
949
950 #[test]
951 fn export_html_creates_file() {
952 let dir = tempfile::tempdir().unwrap();
953 let kg = sample_graph();
954 let communities: HashMap<usize, Vec<String>> =
955 [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
956 let labels: HashMap<usize, String> =
957 [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
958
959 let path = export_html(&kg, &communities, &labels, dir.path()).unwrap();
960 assert!(path.exists());
961
962 let content = std::fs::read_to_string(&path).unwrap();
963 assert!(content.contains("vis-network"));
964 assert!(content.contains("NodeA"));
965 assert!(content.contains("forceAtlas2Based"));
966 }
967
968 #[test]
969 fn escape_js_special_chars() {
970 assert_eq!(escape_js("a\"b"), r#"a\"b"#);
971 assert_eq!(escape_js("a\nb"), r"a\nb");
972 }
973
974 #[test]
975 fn escape_html_special_chars() {
976 assert_eq!(escape_html("<b>hi</b>"), "<b>hi</b>");
977 }
978
979 #[test]
980 fn prune_nodes_caps_at_max() {
981 let mut kg = KnowledgeGraph::new();
983 for i in 0..100 {
984 kg.add_node(GraphNode {
985 id: format!("n{}", i),
986 label: format!("Node{}", i),
987 source_file: "test.rs".into(),
988 source_location: None,
989 node_type: NodeType::Function,
990 community: Some(i % 3),
991 extra: HashMap::new(),
992 })
993 .unwrap();
994 }
995 for i in 0..50 {
997 let _ = kg.add_edge(GraphEdge {
998 source: "n0".into(),
999 target: format!("n{}", i + 1),
1000 relation: "calls".into(),
1001 confidence: Confidence::Extracted,
1002 confidence_score: 1.0,
1003 source_file: "test.rs".into(),
1004 source_location: None,
1005 weight: 1.0,
1006 extra: HashMap::new(),
1007 });
1008 }
1009
1010 let communities: HashMap<usize, Vec<String>> = HashMap::from([
1011 (0, (0..34).map(|i| format!("n{}", i)).collect()),
1012 (1, (34..67).map(|i| format!("n{}", i)).collect()),
1013 (2, (67..100).map(|i| format!("n{}", i)).collect()),
1014 ]);
1015
1016 let pruned = prune_nodes(&kg, &communities, 20);
1017 assert!(pruned.len() <= 20, "should cap at 20, got {}", pruned.len());
1018 assert!(
1020 pruned.contains("n0"),
1021 "highest-degree node should be included"
1022 );
1023 }
1024
1025 #[test]
1026 fn export_html_split_creates_files() {
1027 let dir = tempfile::tempdir().unwrap();
1028 let kg = sample_graph();
1029 let communities: HashMap<usize, Vec<String>> =
1030 [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
1031 let labels: HashMap<usize, String> =
1032 [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
1033
1034 let path = export_html_split(&kg, &communities, &labels, dir.path()).unwrap();
1035 assert!(path.exists());
1036 assert!(path.join("index.html").exists(), "index.html should exist");
1037 assert!(
1038 path.join("community_0.html").exists(),
1039 "community_0.html should exist"
1040 );
1041 assert!(
1042 path.join("community_1.html").exists(),
1043 "community_1.html should exist"
1044 );
1045
1046 let index = std::fs::read_to_string(path.join("index.html")).unwrap();
1047 assert!(index.contains("Overview"));
1048 assert!(index.contains("Cluster A"));
1049 assert!(index.contains("community_0.html"));
1050
1051 let c0 = std::fs::read_to_string(path.join("community_0.html")).unwrap();
1052 assert!(c0.contains("Cluster A"));
1053 assert!(c0.contains("index.html"));
1054 }
1055}