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