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
12const COMMUNITY_COLORS: &[&str] = &[
13    "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F", "#EDC948", "#B07AA1", "#FF9DA7",
14    "#9C755F", "#BAB0AC",
15];
16
17/// Soft limit: above this we prune to top nodes.
18const PRUNE_THRESHOLD: usize = 2000;
19/// Hard maximum nodes after pruning.
20const MAX_VIS_NODES: usize = 2000;
21
22/// Export an interactive HTML visualization of the knowledge graph.
23///
24/// For large graphs (> 2000 nodes), automatically prunes to the most important
25/// nodes: highest-degree nodes plus community representatives, capped at 2000.
26pub 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    // Determine which nodes to include
36    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    // Build reverse lookup: node_id → community_id
52    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    // Build vis.js nodes JSON array
60    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        // Scale node size by degree
78        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    // Build vis.js edges JSON array (only edges between included nodes)
99    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    // Build legend HTML
138    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    // Build hyperedge info
151    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    // Banner for pruned graphs
164    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
192/// Select the most important nodes for visualization when the graph is too large.
193///
194/// Strategy:
195/// 1. Include top N nodes by degree (hub nodes)
196/// 2. Include at least 1 representative from each community
197/// 3. Cap at `max_nodes`
198fn 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    // 1. Add top nodes by degree
206    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    // Reserve slots for community representatives
217    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    // 2. Add community representatives (highest-degree node per community)
225    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('&', "&amp;")
248        .replace('<', "&lt;")
249        .replace('>', "&gt;")
250        .replace('"', "&quot;")
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    // For large graphs: disable physics after stabilization, use Barnes-Hut
262    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    // For large graphs: hide edge labels, smaller fonts
290    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
444// ---------------------------------------------------------------------------
445// Split HTML export: index (overview) + per-community pages
446// ---------------------------------------------------------------------------
447
448/// Export a split HTML visualization into `output_dir/html/`.
449///
450/// Generates:
451/// - `html/index.html` — overview page where each community is a single super-node,
452///   edges represent cross-community connections. Click a community to navigate.
453/// - `html/community_N.html` — detail page for community N with all its internal
454///   nodes and edges. Links back to index and to other communities.
455///
456/// Returns the path to the `html/` directory.
457pub 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    // Build reverse lookup
467    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 index.html (overview) ──
475    generate_overview(
476        &html_dir,
477        graph,
478        communities,
479        community_labels,
480        &node_community,
481    )?;
482
483    // ── Generate per-community pages ──
484    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
511/// Generate the overview index.html with communities as super-nodes.
512fn 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    // Build super-nodes (one per community)
520    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    // Build super-edges (cross-community connections, aggregated)
553    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    // Navigation links
586    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
682/// Generate a detail page for a single community.
683fn 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    // Build nodes
696    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    // Build edges (internal only)
725    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    // Cross-community connections summary
757    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>"), "&lt;b&gt;hi&lt;/b&gt;");
977    }
978
979    #[test]
980    fn prune_nodes_caps_at_max() {
981        // Build a graph with 100 nodes
982        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        // Add some edges to give nodes different degrees
996        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        // n0 (highest degree) must be included
1019        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}