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 (default, overridable via `max_nodes`).
18const DEFAULT_MAX_VIS_NODES: usize = 2000;
19
20/// Export an interactive HTML visualization of the knowledge graph.
21///
22/// For large graphs (> `max_nodes` nodes), automatically prunes to the most
23/// important nodes: highest-degree nodes plus community representatives.
24/// Pass `None` for `max_nodes` to use the default of 2000.
25pub 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    // Determine which nodes to include
37    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    // Build reverse lookup: node_id → community_id
53    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    // Build vis.js nodes JSON array
61    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        // Scale node size by degree
79        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    // Build vis.js edges JSON array (only edges between included nodes)
100    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    // Build legend HTML
139    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    // Build hyperedge info
152    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    // Banner for pruned graphs
165    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
193/// Select the most important nodes for visualization when the graph is too large.
194///
195/// Strategy:
196/// 1. Include top N nodes by degree (hub nodes)
197/// 2. Include at least 1 representative from each community
198/// 3. Cap at `max_nodes`
199fn 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    // 1. Add top nodes by degree
207    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    // Reserve slots for community representatives
218    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    // 2. Add community representatives (highest-degree node per community)
226    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('&', "&amp;")
249        .replace('<', "&lt;")
250        .replace('>', "&gt;")
251        .replace('"', "&quot;")
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    // For large graphs: disable physics after stabilization, use Barnes-Hut
263    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    // For large graphs: hide edge labels, smaller fonts
291    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
445// ---------------------------------------------------------------------------
446// Split HTML export: index (overview) + per-community pages
447// ---------------------------------------------------------------------------
448
449/// Export a split HTML visualization into `output_dir/html/`.
450///
451/// Generates:
452/// - `html/index.html` — overview page where each community is a single super-node,
453///   edges represent cross-community connections. Click a community to navigate.
454/// - `html/community_N.html` — detail page for community N with all its internal
455///   nodes and edges. Links back to index and to other communities.
456///
457/// Returns the path to the `html/` directory.
458pub 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    // Build reverse lookup
468    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 index.html (overview) ──
476    generate_overview(
477        &html_dir,
478        graph,
479        communities,
480        community_labels,
481        &node_community,
482    )?;
483
484    // ── Generate per-community pages ──
485    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
512/// Generate the overview index.html with communities as super-nodes.
513fn 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    // Build super-nodes (one per community)
521    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    // Build super-edges (cross-community connections, aggregated)
554    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    // Navigation links
587    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
683/// Generate a detail page for a single community.
684fn 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    // Build nodes
697    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    // Build edges (internal only)
726    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    // Cross-community connections summary
758    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>"), "&lt;b&gt;hi&lt;/b&gt;");
978    }
979
980    #[test]
981    fn prune_nodes_caps_at_max() {
982        // Build a graph with 100 nodes
983        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        // Add some edges to give nodes different degrees
997        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        // n0 (highest degree) must be included
1020        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        // Build a graph with 10 nodes
1060        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        // With max_nodes=5, should prune (10 > 5)
1093        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        // n0 is highest degree, must appear
1097        assert!(html.contains("Node0"));
1098        // Pruning banner should appear
1099        assert!(
1100            html.contains("pruned") || html.contains("Showing"),
1101            "should indicate pruning occurred"
1102        );
1103    }
1104}