Skip to main content

graphify_export/
html.rs

1//! Interactive vis.js HTML export.
2
3use 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
12#[path = "html_templates.rs"]
13mod html_templates;
14use html_templates::{build_html_template, escape_html, escape_js};
15
16const COMMUNITY_COLORS: &[&str] = &[
17    "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F", "#EDC948", "#B07AA1", "#FF9DA7",
18    "#9C755F", "#BAB0AC",
19];
20
21/// Soft limit: above this we prune to top nodes (default, overridable via `max_nodes`).
22const DEFAULT_MAX_VIS_NODES: usize = 2000;
23
24/// Export an interactive HTML visualization of the knowledge graph.
25///
26/// For large graphs (> `max_nodes` nodes), automatically prunes to the most
27/// important nodes: highest-degree nodes plus community representatives.
28/// Pass `None` for `max_nodes` to use the default of 2000.
29pub fn export_html(
30    graph: &KnowledgeGraph,
31    communities: &HashMap<usize, Vec<String>>,
32    community_labels: &HashMap<usize, String>,
33    output_dir: &Path,
34    max_nodes: Option<usize>,
35) -> anyhow::Result<PathBuf> {
36    let max_vis = max_nodes.unwrap_or(DEFAULT_MAX_VIS_NODES);
37    let total_nodes = graph.node_count();
38    let total_edges = graph.edge_count();
39
40    let (included_nodes, pruned) = if total_nodes > max_vis {
41        warn!(
42            total_nodes,
43            threshold = max_vis,
44            "graph too large for interactive viz, pruning to top {} nodes",
45            max_vis
46        );
47        (prune_nodes(graph, communities, max_vis), true)
48    } else {
49        (
50            graph.node_ids().into_iter().collect::<HashSet<String>>(),
51            false,
52        )
53    };
54
55    let node_community = graphify_core::build_node_to_community(communities);
56    let mut vis_nodes = String::from("[");
57    let mut first = true;
58    for node in graph.nodes() {
59        if !included_nodes.contains(&node.id) {
60            continue;
61        }
62        if !first {
63            vis_nodes.push(',');
64        }
65        first = false;
66        let cid = node
67            .community
68            .or_else(|| node_community.get(node.id.as_str()).copied());
69        let color = cid.map_or("#888888", |c| COMMUNITY_COLORS[c % COMMUNITY_COLORS.len()]);
70        let degree = graph.degree(&node.id);
71        let size = 8.0 + (degree as f64).sqrt() * 4.0;
72        let label_escaped = escape_js(&node.label);
73        let title_escaped = escape_js(&format!(
74            "{} ({})\nFile: {}\nType: {}\nDegree: {}",
75            node.label, node.id, node.source_file, node.node_type, degree
76        ));
77        write!(
78            vis_nodes,
79            r#"{{id:"{}",label:"{}",title:"{}",color:"{}",community:{},size:{:.1}}}"#,
80            escape_js(&node.id),
81            label_escaped,
82            title_escaped,
83            color,
84            cid.unwrap_or(0),
85            size,
86        )?;
87    }
88    vis_nodes.push(']');
89
90    let mut vis_edges = String::from("[");
91    first = true;
92    for edge in graph.edges() {
93        if !included_nodes.contains(&edge.source) || !included_nodes.contains(&edge.target) {
94            continue;
95        }
96        if !first {
97            vis_edges.push(',');
98        }
99        first = false;
100        let dashes = match edge.confidence {
101            Confidence::Extracted => "false",
102            Confidence::Inferred | Confidence::Ambiguous => "true",
103        };
104        let width = 1.0 + edge.confidence_score * 2.0;
105        let title_escaped = escape_js(&format!(
106            "{}: {} β†’ {}\nConfidence: {} ({:.2})\nFile: {}",
107            edge.relation,
108            edge.source,
109            edge.target,
110            edge.confidence,
111            edge.confidence_score,
112            edge.source_file
113        ));
114        write!(
115            vis_edges,
116            r#"{{from:"{}",to:"{}",label:"{}",title:"{}",dashes:{},width:{:.1}}}"#,
117            escape_js(&edge.source),
118            escape_js(&edge.target),
119            escape_js(&edge.relation),
120            title_escaped,
121            dashes,
122            width,
123        )?;
124    }
125    vis_edges.push(']');
126
127    let mut legend_html = String::new();
128    for (&cid, label) in community_labels {
129        let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
130        write!(
131            legend_html,
132            r#"<div class="legend-item"><span class="legend-dot" style="background:{}"></span>{}</div>"#,
133            color,
134            escape_html(label),
135        )?;
136    }
137
138    let mut hyperedge_html = String::new();
139    for he in &graph.hyperedges {
140        write!(
141            hyperedge_html,
142            "<li><b>{}</b>: {} ({})</li>",
143            escape_html(&he.relation),
144            escape_html(&he.label),
145            he.nodes.join(", "),
146        )?;
147    }
148
149    let prune_banner = if pruned {
150        format!(
151            r#"<div id="prune-banner">Showing top {} of {} nodes ({} edges total). Only highest-degree nodes and community representatives are displayed.</div>"#,
152            included_nodes.len(),
153            total_nodes,
154            total_edges,
155        )
156    } else {
157        String::new()
158    };
159
160    let is_large = included_nodes.len() > 500;
161    let html = build_html_template(
162        &vis_nodes,
163        &vis_edges,
164        &legend_html,
165        &hyperedge_html,
166        &prune_banner,
167        is_large,
168    );
169
170    fs::create_dir_all(output_dir)?;
171    let path = output_dir.join("graph.html");
172    fs::write(&path, &html)?;
173    info!(path = %path.display(), nodes = included_nodes.len(), "exported interactive HTML visualization");
174    Ok(path)
175}
176
177/// Select the most important nodes for visualization when the graph is too large.
178///
179/// Strategy:
180/// 1. Include top N nodes by degree (hub nodes)
181/// 2. Include at least 1 representative from each community
182/// 3. Cap at `max_nodes`
183fn prune_nodes(
184    graph: &KnowledgeGraph,
185    communities: &HashMap<usize, Vec<String>>,
186    max_nodes: usize,
187) -> HashSet<String> {
188    let mut included: HashSet<String> = HashSet::new();
189
190    let mut by_degree: Vec<(String, usize)> = graph
191        .node_ids()
192        .into_iter()
193        .map(|id| {
194            let deg = graph.degree(&id);
195            (id, deg)
196        })
197        .collect();
198    by_degree.sort_by_key(|b| std::cmp::Reverse(b.1));
199
200    let community_slots = communities.len().min(max_nodes / 4);
201    let degree_slots = max_nodes.saturating_sub(community_slots);
202
203    for (id, _) in by_degree.iter().take(degree_slots) {
204        included.insert(id.clone());
205    }
206
207    for members in communities.values() {
208        if included.len() >= max_nodes {
209            break;
210        }
211        let best = members.iter().max_by_key(|id| graph.degree(id)).cloned();
212        if let Some(id) = best {
213            included.insert(id);
214        }
215    }
216
217    included
218}
219
220
221/// Export a split HTML visualization into `output_dir/html/`.
222///
223/// Generates:
224/// - `html/index.html` β€” overview page where each community is a single super-node,
225///   edges represent cross-community connections. Click a community to navigate.
226/// - `html/community_N.html` β€” detail page for community N with all its internal
227///   nodes and edges. Links back to index and to other communities.
228///
229/// Returns the path to the `html/` directory.
230pub fn export_html_split(
231    graph: &KnowledgeGraph,
232    communities: &HashMap<usize, Vec<String>>,
233    community_labels: &HashMap<usize, String>,
234    output_dir: &Path,
235) -> anyhow::Result<PathBuf> {
236    let html_dir = output_dir.join("html");
237    fs::create_dir_all(&html_dir)?;
238
239    let node_community = graphify_core::build_node_to_community(communities);
240    generate_overview(
241        &html_dir,
242        graph,
243        communities,
244        community_labels,
245        &node_community,
246    )?;
247
248    let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
249    sorted_cids.sort_unstable();
250    for &cid in &sorted_cids {
251        let members = &communities[&cid];
252        let label = community_labels
253            .get(&cid)
254            .cloned()
255            .unwrap_or_else(|| format!("Community {cid}"));
256        generate_community_page(
257            &html_dir,
258            graph,
259            cid,
260            &label,
261            members,
262            community_labels,
263            &node_community,
264        )?;
265    }
266
267    info!(
268        path = %html_dir.display(),
269        communities = communities.len(),
270        "exported split HTML visualization"
271    );
272    Ok(html_dir)
273}
274
275/// Generate the overview index.html with communities as super-nodes.
276fn generate_overview(
277    html_dir: &Path,
278    graph: &KnowledgeGraph,
279    communities: &HashMap<usize, Vec<String>>,
280    community_labels: &HashMap<usize, String>,
281    node_community: &HashMap<&str, usize>,
282) -> anyhow::Result<()> {
283    let mut vis_nodes = String::from("[");
284    let mut first = true;
285    for (&cid, members) in communities {
286        if !first {
287            vis_nodes.push(',');
288        }
289        first = false;
290        let label = community_labels
291            .get(&cid)
292            .cloned()
293            .unwrap_or_else(|| format!("Community {cid}"));
294        let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
295        let size = 20.0 + (members.len() as f64).sqrt() * 5.0;
296        let title = format!(
297            "{} ({} nodes)\\nClick to view details",
298            label,
299            members.len()
300        );
301        write!(
302            vis_nodes,
303            r#"{{id:{cid},label:"{label} ({count})",title:"{title}",color:"{color}",size:{size:.1},url:"community_{cid}.html"}}"#,
304            cid = cid,
305            label = escape_js(&label),
306            count = members.len(),
307            title = escape_js(&title),
308            color = color,
309            size = size,
310        )?;
311    }
312    vis_nodes.push(']');
313
314    let mut cross_edges: HashMap<(usize, usize), usize> = HashMap::new();
315    for edge in graph.edges() {
316        let src_cid = node_community.get(edge.source.as_str()).copied();
317        let tgt_cid = node_community.get(edge.target.as_str()).copied();
318        if let (Some(sc), Some(tc)) = (src_cid, tgt_cid)
319            && sc != tc
320        {
321            let key = if sc < tc { (sc, tc) } else { (tc, sc) };
322            *cross_edges.entry(key).or_default() += 1;
323        }
324    }
325
326    let mut vis_edges = String::from("[");
327    first = true;
328    for ((from, to), count) in &cross_edges {
329        if !first {
330            vis_edges.push(',');
331        }
332        first = false;
333        let width = 1.0 + (*count as f64).sqrt();
334        write!(
335            vis_edges,
336            r#"{{from:{from},to:{to},label:"{count}",width:{width:.1},title:"{count} cross-community edges"}}"#,
337        )?;
338    }
339    vis_edges.push(']');
340
341    let mut nav_html = String::new();
342    let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
343    sorted_cids.sort_unstable();
344    for cid in &sorted_cids {
345        let label = community_labels
346            .get(cid)
347            .cloned()
348            .unwrap_or_else(|| format!("Community {cid}"));
349        let color = COMMUNITY_COLORS[*cid % COMMUNITY_COLORS.len()];
350        let count = communities[cid].len();
351        write!(
352            nav_html,
353            r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count})</a>"#,
354            cid = cid,
355            color = color,
356            label = escape_html(&label),
357            count = count,
358        )?;
359    }
360
361    let html = format!(
362        r#"<!DOCTYPE html>
363<html lang="en">
364<head>
365<meta charset="utf-8">
366<meta name="viewport" content="width=device-width, initial-scale=1">
367<title>Knowledge Graph β€” Overview</title>
368<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
369<style>
370* {{ margin: 0; padding: 0; box-sizing: border-box; }}
371body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
372#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; }}
373#sidebar h2 {{ font-size: 18px; color: #76B7B2; margin-bottom: 4px; }}
374#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
375.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
376.nav-link:hover {{ background: #2a2a4a; }}
377.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
378#graph-container {{ flex: 1; position: relative; }}
379#info {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; color: #9ca3af; }}
380</style>
381</head>
382<body>
383<div id="sidebar">
384    <div>
385        <h2>🧠 Overview</h2>
386        <p style="font-size:12px;color:#666;">Each node is a community. Click to view details.</p>
387    </div>
388    <div id="info">{node_count} nodes, {edge_count} edges, {community_count} communities</div>
389    <div>
390        <h3>Communities</h3>
391        {nav}
392    </div>
393</div>
394<div id="graph-container"></div>
395<script>
396(function() {{
397    var nodesData = {nodes};
398    var edgesData = {edges};
399    var container = document.getElementById('graph-container');
400    var nodes = new vis.DataSet(nodesData);
401    var edges = new vis.DataSet(edgesData);
402    var options = {{
403        physics: {{
404            solver: 'forceAtlas2Based',
405            forceAtlas2Based: {{ gravitationalConstant: -100, centralGravity: 0.01, springLength: 200, springConstant: 0.05, damping: 0.4 }},
406            stabilization: {{ iterations: 100 }}
407        }},
408        nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 14, multi: true }}, borderWidth: 2 }},
409        edges: {{ color: {{ color: '#4a4a6a' }}, font: {{ color: '#888', size: 12 }}, smooth: {{ type: 'continuous' }} }},
410        interaction: {{ hover: true, zoomView: true, dragView: true }}
411    }};
412    var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
413    network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
414    network.on('doubleClick', function(params) {{
415        if (params.nodes.length > 0) {{
416            var node = nodes.get(params.nodes[0]);
417            if (node && node.url) {{ window.location.href = node.url; }}
418        }}
419    }});
420}})();
421</script>
422</body>
423</html>"#,
424        nodes = vis_nodes,
425        edges = vis_edges,
426        nav = nav_html,
427        node_count = graph.node_count(),
428        edge_count = graph.edge_count(),
429        community_count = communities.len(),
430    );
431
432    fs::write(html_dir.join("index.html"), &html)?;
433    Ok(())
434}
435
436/// Generate a detail page for a single community.
437fn generate_community_page(
438    html_dir: &Path,
439    graph: &KnowledgeGraph,
440    cid: usize,
441    label: &str,
442    members: &[String],
443    community_labels: &HashMap<usize, String>,
444    node_community: &HashMap<&str, usize>,
445) -> anyhow::Result<()> {
446    let member_set: HashSet<&str> = members.iter().map(std::string::String::as_str).collect();
447    let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
448
449    let mut vis_nodes = String::from("[");
450    let mut first = true;
451    for node in graph.nodes() {
452        if !member_set.contains(node.id.as_str()) {
453            continue;
454        }
455        if !first {
456            vis_nodes.push(',');
457        }
458        first = false;
459        let degree = graph.degree(&node.id);
460        let size = 8.0 + (degree as f64).sqrt() * 4.0;
461        write!(
462            vis_nodes,
463            r#"{{id:"{}",label:"{}",title:"{}",color:"{}",size:{:.1}}}"#,
464            escape_js(&node.id),
465            escape_js(&node.label),
466            escape_js(&format!(
467                "{}\nType: {}\nFile: {}\nDegree: {}",
468                node.label, node.node_type, node.source_file, degree
469            )),
470            color,
471            size,
472        )?;
473    }
474    vis_nodes.push(']');
475
476    let mut vis_edges = String::from("[");
477    first = true;
478    for edge in graph.edges() {
479        if !member_set.contains(edge.source.as_str()) || !member_set.contains(edge.target.as_str())
480        {
481            continue;
482        }
483        if !first {
484            vis_edges.push(',');
485        }
486        first = false;
487        let dashes = match edge.confidence {
488            Confidence::Extracted => "false",
489            _ => "true",
490        };
491        write!(
492            vis_edges,
493            r#"{{from:"{}",to:"{}",label:"{}",dashes:{},title:"{}"}}"#,
494            escape_js(&edge.source),
495            escape_js(&edge.target),
496            escape_js(&edge.relation),
497            dashes,
498            escape_js(&format!(
499                "{}: {} β†’ {}\nConfidence: {}",
500                edge.relation, edge.source, edge.target, edge.confidence
501            )),
502        )?;
503    }
504    vis_edges.push(']');
505
506    let mut external_links: HashMap<usize, usize> = HashMap::new();
507    for node_id in members {
508        for edge in graph.edges() {
509            let other = if edge.source == *node_id {
510                &edge.target
511            } else if edge.target == *node_id {
512                &edge.source
513            } else {
514                continue;
515            };
516            if let Some(&other_cid) = node_community.get(other.as_str())
517                && other_cid != cid
518            {
519                *external_links.entry(other_cid).or_default() += 1;
520            }
521        }
522    }
523
524    let mut nav_html = String::from(
525        r#"<a href="index.html" class="nav-link" style="font-weight:bold;">← Overview</a>"#,
526    );
527    let mut sorted_ext: Vec<(usize, usize)> = external_links.into_iter().collect();
528    sorted_ext.sort_by_key(|b| std::cmp::Reverse(b.1));
529    for (ext_cid, count) in &sorted_ext {
530        let ext_label = community_labels
531            .get(ext_cid)
532            .cloned()
533            .unwrap_or_else(|| format!("Community {ext_cid}"));
534        let ext_color = COMMUNITY_COLORS[*ext_cid % COMMUNITY_COLORS.len()];
535        write!(
536            nav_html,
537            r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count} links)</a>"#,
538            cid = ext_cid,
539            color = ext_color,
540            label = escape_html(&ext_label),
541            count = count,
542        )?;
543    }
544
545    let is_large = members.len() > 500;
546    let physics = if is_large {
547        "solver:'barnesHut',barnesHut:{gravitationalConstant:-3000,springLength:95,damping:0.09},stabilization:{iterations:150}"
548    } else {
549        "solver:'forceAtlas2Based',forceAtlas2Based:{gravitationalConstant:-50,centralGravity:0.01,springLength:120,springConstant:0.08,damping:0.4,avoidOverlap:0.5},stabilization:{iterations:200}"
550    };
551    let edge_font = if is_large { 0 } else { 10 };
552
553    let html = format!(
554        r#"<!DOCTYPE html>
555<html lang="en">
556<head>
557<meta charset="utf-8">
558<meta name="viewport" content="width=device-width, initial-scale=1">
559<title>{title}</title>
560<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
561<style>
562* {{ margin: 0; padding: 0; box-sizing: border-box; }}
563body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
564#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; }}
565#sidebar h2 {{ font-size: 18px; color: {color}; margin-bottom: 4px; }}
566#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
567.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
568.nav-link:hover {{ background: #2a2a4a; }}
569.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
570#graph-container {{ flex: 1; position: relative; }}
571#search {{ width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: #0f0f1a; color: #e0e0e0; font-size: 14px; }}
572#search:focus {{ outline: none; border-color: {color}; }}
573#info-panel {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; min-height: 100px; }}
574#info-panel .prop {{ color: #9ca3af; }}
575#info-panel .val {{ color: #e0e0e0; }}
576</style>
577</head>
578<body>
579<div id="sidebar">
580    <div>
581        <h2>{label}</h2>
582        <p style="font-size:12px;color:#666;">{node_count} nodes Β· Community {cid}</p>
583    </div>
584    <input id="search" type="text" placeholder="Search nodes…" />
585    <div>
586        <h3>Node Info</h3>
587        <div id="info-panel"><i style="color:#666">Click a node to see details</i></div>
588    </div>
589    <div>
590        <h3>Navigation</h3>
591        {nav}
592    </div>
593</div>
594<div id="graph-container"></div>
595<script>
596(function() {{
597    var nodesData = {nodes};
598    var edgesData = {edges};
599    var container = document.getElementById('graph-container');
600    var nodes = new vis.DataSet(nodesData);
601    var edges = new vis.DataSet(edgesData);
602    var options = {{
603        physics: {{{physics}}},
604        nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 12 }}, borderWidth: 2 }},
605        edges: {{ color: {{ color: '#4a4a6a', highlight: '{color}', hover: '{color}' }}, font: {{ color: '#888', size: {edge_font} }}, arrows: {{ to: {{ enabled: false }} }}, smooth: {{ type: 'continuous' }} }},
606        interaction: {{ hover: true, tooltipDelay: 200, zoomView: true, dragView: true }}
607    }};
608    var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
609    network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
610    network.on('click', function(params) {{
611        var panel = document.getElementById('info-panel');
612        if (params.nodes.length > 0) {{
613            var node = nodes.get(params.nodes[0]);
614            if (node) {{
615                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>';
616                network.focus(params.nodes[0], {{ scale: 1.2, animation: true }});
617            }}
618        }}
619    }});
620    var searchEl = document.getElementById('search');
621    var sTimer = null;
622    searchEl.addEventListener('input', function() {{
623        clearTimeout(sTimer);
624        sTimer = setTimeout(function() {{
625            var term = searchEl.value.toLowerCase();
626            var updates = [];
627            nodes.forEach(function(n) {{
628                var h = term && !n.label.toLowerCase().includes(term);
629                if (n.hidden !== h) {{ updates.push({{ id: n.id, hidden: h }}); }}
630            }});
631            if (updates.length > 0) {{ nodes.update(updates); }}
632        }}, 200);
633    }});
634}})();
635</script>
636</body>
637</html>"#,
638        title = escape_html(&format!("{label} β€” Community {cid}")),
639        color = color,
640        label = escape_html(label),
641        cid = cid,
642        node_count = members.len(),
643        nodes = vis_nodes,
644        edges = vis_edges,
645        nav = nav_html,
646        physics = physics,
647        edge_font = edge_font,
648    );
649
650    fs::write(html_dir.join(format!("community_{cid}.html")), &html)?;
651    Ok(())
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use graphify_core::confidence::Confidence;
658    use graphify_core::graph::KnowledgeGraph;
659    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
660
661    fn sample_graph() -> KnowledgeGraph {
662        let mut kg = KnowledgeGraph::new();
663        kg.add_node(GraphNode {
664            id: "a".into(),
665            label: "NodeA".into(),
666            source_file: "test.rs".into(),
667            source_location: None,
668            node_type: NodeType::Class,
669            community: Some(0),
670            extra: HashMap::new(),
671        })
672        .unwrap();
673        kg.add_node(GraphNode {
674            id: "b".into(),
675            label: "NodeB".into(),
676            source_file: "test.rs".into(),
677            source_location: None,
678            node_type: NodeType::Function,
679            community: Some(1),
680            extra: HashMap::new(),
681        })
682        .unwrap();
683        kg.add_edge(GraphEdge {
684            source: "a".into(),
685            target: "b".into(),
686            relation: "calls".into(),
687            confidence: Confidence::Inferred,
688            confidence_score: 0.7,
689            source_file: "test.rs".into(),
690            source_location: None,
691            weight: 1.0,
692            extra: HashMap::new(),
693        })
694        .unwrap();
695        kg
696    }
697
698    #[test]
699    fn export_html_creates_file() {
700        let dir = tempfile::tempdir().unwrap();
701        let kg = sample_graph();
702        let communities: HashMap<usize, Vec<String>> =
703            [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
704        let labels: HashMap<usize, String> =
705            [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
706
707        let path = export_html(&kg, &communities, &labels, dir.path(), None).unwrap();
708        assert!(path.exists());
709
710        let content = std::fs::read_to_string(&path).unwrap();
711        assert!(content.contains("vis-network"));
712        assert!(content.contains("NodeA"));
713        assert!(content.contains("forceAtlas2Based"));
714    }
715
716    #[test]
717    fn escape_js_special_chars() {
718        assert_eq!(escape_js("a\"b"), r#"a\"b"#);
719        assert_eq!(escape_js("a\nb"), r"a\nb");
720    }
721
722    #[test]
723    fn escape_html_special_chars() {
724        assert_eq!(escape_html("<b>hi</b>"), "&lt;b&gt;hi&lt;/b&gt;");
725    }
726
727    #[test]
728    fn prune_nodes_caps_at_max() {
729        let mut kg = KnowledgeGraph::new();
730        for i in 0..100 {
731            kg.add_node(GraphNode {
732                id: format!("n{}", i),
733                label: format!("Node{}", i),
734                source_file: "test.rs".into(),
735                source_location: None,
736                node_type: NodeType::Function,
737                community: Some(i % 3),
738                extra: HashMap::new(),
739            })
740            .unwrap();
741        }
742        for i in 0..50 {
743            let _ = kg.add_edge(GraphEdge {
744                source: "n0".into(),
745                target: format!("n{}", i + 1),
746                relation: "calls".into(),
747                confidence: Confidence::Extracted,
748                confidence_score: 1.0,
749                source_file: "test.rs".into(),
750                source_location: None,
751                weight: 1.0,
752                extra: HashMap::new(),
753            });
754        }
755
756        let communities: HashMap<usize, Vec<String>> = HashMap::from([
757            (0, (0..34).map(|i| format!("n{}", i)).collect()),
758            (1, (34..67).map(|i| format!("n{}", i)).collect()),
759            (2, (67..100).map(|i| format!("n{}", i)).collect()),
760        ]);
761
762        let pruned = prune_nodes(&kg, &communities, 20);
763        assert!(pruned.len() <= 20, "should cap at 20, got {}", pruned.len());
764        assert!(
765            pruned.contains("n0"),
766            "highest-degree node should be included"
767        );
768    }
769
770    #[test]
771    fn export_html_split_creates_files() {
772        let dir = tempfile::tempdir().unwrap();
773        let kg = sample_graph();
774        let communities: HashMap<usize, Vec<String>> =
775            [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
776        let labels: HashMap<usize, String> =
777            [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
778
779        let path = export_html_split(&kg, &communities, &labels, dir.path()).unwrap();
780        assert!(path.exists());
781        assert!(path.join("index.html").exists(), "index.html should exist");
782        assert!(
783            path.join("community_0.html").exists(),
784            "community_0.html should exist"
785        );
786        assert!(
787            path.join("community_1.html").exists(),
788            "community_1.html should exist"
789        );
790
791        let index = std::fs::read_to_string(path.join("index.html")).unwrap();
792        assert!(index.contains("Overview"));
793        assert!(index.contains("Cluster A"));
794        assert!(index.contains("community_0.html"));
795
796        let c0 = std::fs::read_to_string(path.join("community_0.html")).unwrap();
797        assert!(c0.contains("Cluster A"));
798        assert!(c0.contains("index.html"));
799    }
800
801    #[test]
802    fn export_html_respects_max_nodes() -> anyhow::Result<()> {
803        let mut kg = KnowledgeGraph::new();
804        for i in 0..10 {
805            kg.add_node(GraphNode {
806                id: format!("n{i}"),
807                label: format!("Node{i}"),
808                source_file: "test.rs".into(),
809                source_location: None,
810                node_type: NodeType::Function,
811                community: Some(0),
812                extra: HashMap::new(),
813            })
814            .unwrap();
815        }
816        for i in 1..10 {
817            let _ = kg.add_edge(GraphEdge {
818                source: "n0".into(),
819                target: format!("n{i}"),
820                relation: "calls".into(),
821                confidence: Confidence::Extracted,
822                confidence_score: 1.0,
823                source_file: "test.rs".into(),
824                source_location: None,
825                weight: 1.0,
826                extra: HashMap::new(),
827            });
828        }
829
830        let communities: HashMap<usize, Vec<String>> =
831            [(0, (0..10).map(|i| format!("n{i}")).collect())].into();
832        let labels: HashMap<usize, String> = [(0, "All".into())].into();
833        let dir = tempfile::tempdir().unwrap();
834
835        let path = export_html(&kg, &communities, &labels, dir.path(), Some(5)).unwrap();
836        assert!(path.exists());
837        let html = std::fs::read_to_string(&path).unwrap();
838        assert!(html.contains("Node0"));
839        assert!(
840            html.contains("pruned") || html.contains("Showing"),
841            "should indicate pruning occurred"
842        );
843        Ok(())
844    }
845}