use crate::graph::{GraphFile, Note};
use anyhow::Result;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub struct ExportHtmlOptions<'a> {
pub output: Option<&'a str>,
pub title: Option<&'a str>,
}
pub fn export_html(graph: &GraphFile, graph_name: &str, opts: ExportHtmlOptions) -> Result<String> {
let output_path = match opts.output {
Some(p) => p.to_string(),
None => format!("{graph_name}.html"),
};
let title = opts
.title
.unwrap_or(&graph.metadata.name)
.replace('"', """);
let html = render_html(graph, &title);
fs::write(Path::new(&output_path), html)?;
Ok(format!("+ exported {output_path}\n"))
}
fn escape_js_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn format_note(note: &Note) -> String {
let mut out = String::new();
if !note.created_at.is_empty() {
out.push_str(¬e.created_at);
out.push(' ');
}
if !note.author.is_empty() {
out.push_str(¬e.author);
out.push_str(": ");
}
out.push_str(¬e.body);
if !note.tags.is_empty() {
out.push_str(" [");
out.push_str(¬e.tags.join(", "));
out.push(']');
}
out
}
fn node_color(node_type: &str) -> &'static str {
match node_type {
"Concept" => "#4A90D9",
"Process" => "#7ED321",
"DataStore" => "#F5A623",
"Interface" => "#BD10E0",
"Rule" => "#D0021B",
"Feature" => "#50E3C2",
"Decision" => "#B8E986",
"Convention" => "#9B9B9B",
"Note" => "#F8E71C",
"Bug" => "#FF6B6B",
_ => "#CCCCCC",
}
}
fn render_html(graph: &GraphFile, title: &str) -> String {
let mut notes_by_node: HashMap<&str, Vec<String>> = HashMap::new();
for note in &graph.notes {
notes_by_node
.entry(note.node_id.as_str())
.or_default()
.push(format_note(note));
}
let nodes_js = graph
.nodes
.iter()
.map(|n| {
let id = escape_js_string(&n.id);
let name = escape_js_string(&n.name);
let ntype = escape_js_string(&n.r#type);
let desc = escape_js_string(&n.properties.description);
let facts_js = n
.properties
.key_facts
.iter()
.map(|f| format!("\"{}\"", escape_js_string(f)))
.collect::<Vec<_>>()
.join(",");
let aliases_js = n
.properties
.alias
.iter()
.map(|a| format!("\"{}\"", escape_js_string(a)))
.collect::<Vec<_>>()
.join(",");
let sources_js = n
.source_files
.iter()
.map(|s| format!("\"{}\"", escape_js_string(s)))
.collect::<Vec<_>>()
.join(",");
let notes_js = notes_by_node
.get(n.id.as_str())
.map(|notes| {
notes
.iter()
.map(|note| format!("\"{}\"", escape_js_string(note)))
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_default();
let confidence = n
.properties
.confidence
.map(|c| format!("{c:.2}"))
.unwrap_or_default();
let color = node_color(&n.r#type);
format!(
r#"{{data:{{id:"{id}",label:"{name}",type:"{ntype}",desc:"{desc}",facts:[{facts_js}],aliases:[{aliases_js}],sources:[{sources_js}],notes:[{notes_js}],confidence:"{confidence}",color:"{color}"}}}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
let edges_js = graph
.edges
.iter()
.enumerate()
.map(|(i, e)| {
let src = escape_js_string(&e.source_id);
let tgt = escape_js_string(&e.target_id);
let rel = escape_js_string(&e.relation);
let detail = escape_js_string(&e.properties.detail);
format!(
r#"{{data:{{id:"e{i}",source:"{src}",target:"{tgt}",relation:"{rel}",detail:"{detail}"}}}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
let mut types: Vec<String> = graph
.nodes
.iter()
.map(|n| n.r#type.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
types.sort();
let mut relations: Vec<String> = graph
.edges
.iter()
.map(|e| e.relation.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
relations.sort();
let type_checkboxes = types
.iter()
.map(|t| {
let color = node_color(t);
format!(
r#"<label class="filter-label"><input type="checkbox" class="type-filter" value="{t}" checked> <span class="dot" style="background:{color}"></span>{t}</label>"#
)
})
.collect::<Vec<_>>()
.join("\n");
let rel_checkboxes = relations
.iter()
.map(|r| {
format!(
r#"<label class="filter-label rel-filter-label"><input type="checkbox" class="rel-filter" value="{r}" checked> <span class="rel-tag">{r}</span></label>"#
)
})
.collect::<Vec<_>>()
.join("\n");
let mut degree_map: HashMap<&str, usize> = HashMap::new();
for n in &graph.nodes {
degree_map.insert(n.id.as_str(), 0);
}
for e in &graph.edges {
*degree_map.entry(&e.source_id).or_insert(0) += 1;
*degree_map.entry(&e.target_id).or_insert(0) += 1;
}
let mut hubs: Vec<(&str, usize)> = graph
.nodes
.iter()
.map(|n| {
let deg = *degree_map.get(n.id.as_str()).unwrap_or(&0);
(n.id.as_str(), deg)
})
.collect();
hubs.sort_by(|a, b| b.1.cmp(&a.1));
let top_hubs_js: String = hubs
.into_iter()
.take(10)
.map(|(id, deg)| {
let node = graph.nodes.iter().find(|n| n.id.as_str() == id).unwrap();
let color = node_color(&node.r#type);
let label = escape_js_string(&node.name);
let nid = escape_js_string(id);
let ntype = escape_js_string(&node.r#type);
format!(
r#"{{id:"{nid}",label:"{label}",type_:"{ntype}",color:"{color}",degree:{deg}}}"#
)
})
.collect::<Vec<_>>()
.join(",");
let graph_name_escaped = escape_js_string(&graph.metadata.name);
let node_count = graph.nodes.len();
let edge_count = graph.edges.len();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<script src="https://unpkg.com/cytoscape@3.29.2/dist/cytoscape.min.js"></script>
<script src="https://unpkg.com/cytoscape-navigator@2.0.0/cytoscape-navigator.js"></script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0f1117;color:#e0e0e0;height:100vh;display:flex;flex-direction:column;overflow:hidden}}
/* Top bar */
#topbar{{display:flex;align-items:center;gap:10px;padding:8px 16px;background:#1a1d27;border-bottom:1px solid #2a2d3a;flex-shrink:0}}
#topbar h1{{font-size:15px;font-weight:600;color:#a0cfff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;flex-shrink:0}}
#search{{flex:1;max-width:260px;padding:6px 12px;border-radius:6px;border:1px solid #3a3d4a;background:#252833;color:#e0e0e0;font-size:13px;outline:none}}
#search:focus{{border-color:#4a90d9}}
#search::placeholder{{color:#555}}
#stats-bar{{font-size:11px;color:#555;white-space:nowrap;flex-shrink:0}}
.kbd{{display:inline-block;padding:1px 5px;border-radius:3px;background:#252833;border:1px solid #3a3d4a;font-size:9px;color:#555;font-family:monospace}}
.btn{{padding:5px 12px;border-radius:6px;border:1px solid #3a3d4a;background:#252833;color:#aaa;font-size:12px;cursor:pointer;flex-shrink:0}}
.btn:hover{{background:#2f3244;color:#e0e0e0}}
#btn-isolate.isolating{{background:#1e3a5f;border-color:#4a90d9;color:#a0cfff}}
/* Main layout */
#main{{display:flex;flex:1;overflow:hidden}}
/* Sidebar */
#sidebar{{width:260px;flex-shrink:0;background:#13151f;border-right:1px solid #2a2d3a;display:flex;flex-direction:column;overflow:hidden}}
.filter-section{{padding:10px 12px;border-bottom:1px solid #2a2d3a;overflow:hidden}}
.filter-section h3{{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:#555;margin-bottom:7px;display:flex;align-items:center;gap:6px}}
.filter-scroll{{max-height:110px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}}
.filter-scroll::-webkit-scrollbar{{width:3px}}
.filter-scroll::-webkit-scrollbar-thumb{{background:#2a2d3a;border-radius:2px}}
.filter-label{{display:flex;align-items:center;gap:5px;font-size:11px;color:#bbb;padding:2px 0;cursor:pointer;user-select:none;white-space:nowrap}}
.filter-label input{{accent-color:#4a90d9;cursor:pointer;flex-shrink:0}}
.dot{{width:9px;height:9px;border-radius:50%;flex-shrink:0}}
.rel-tag{{font-family:monospace;font-size:10px;color:#7ed321}}
/* Hub section */
#hub-section{{padding:10px 12px;border-bottom:1px solid #2a2d3a;flex-shrink:0;max-height:180px;display:flex;flex-direction:column;overflow:hidden}}
#hub-section h3{{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:#555;margin-bottom:7px;flex-shrink:0}}
#hub-list{{overflow-y:auto;display:flex;flex-direction:column;gap:2px}}
#hub-list::-webkit-scrollbar{{width:3px}}
#hub-list::-webkit-scrollbar-thumb{{background:#2a2d3a;border-radius:2px}}
.hub-item{{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:5px;cursor:pointer;font-size:11px;color:#ccc;transition:background 100ms}}
.hub-item:hover{{background:#1e2133}}
.hub-dot{{width:8px;height:8px;border-radius:50%;flex-shrink:0}}
.hub-name{{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.hub-deg{{font-size:10px;color:#444;flex-shrink:0;min-width:18px;text-align:right}}
.hub-item.active{{background:#1a2440}}
/* Detail section */
#detail-section{{flex:1;overflow-y:auto;padding:10px 12px}}
#detail-section h3{{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:#555;margin-bottom:8px}}
#detail-content{{font-size:12px;line-height:1.6;color:#ccc}}
#detail-content .detail-empty{{color:#444;font-style:italic}}
#detail-content .d-id{{font-family:monospace;font-size:10px;color:#4a90d9;word-break:break-all;margin-bottom:5px}}
#detail-content .d-type{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;color:#fff;margin-bottom:6px}}
#detail-content .d-name{{font-size:14px;font-weight:600;color:#e8e8e8;margin-bottom:5px}}
#detail-content .d-desc{{color:#aaa;margin-bottom:8px;line-height:1.5}}
#detail-content .d-section{{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#444;margin:8px 0 3px}}
#detail-content .d-pill{{display:inline-block;padding:2px 6px;border-radius:4px;background:#252833;border:1px solid #3a3d4a;font-size:10px;color:#aaa;margin:2px 2px 2px 0}}
#detail-content .d-fact{{padding:3px 0;border-bottom:1px solid #1e2030;color:#bbb;font-size:11px}}
#detail-content .d-note{{padding:3px 0;border-bottom:1px solid #1e2030;color:#bbb;font-size:11px}}
#detail-content .d-edge{{display:flex;align-items:flex-start;gap:4px;padding:3px 0;font-size:10px;border-bottom:1px solid #1e2030}}
#detail-content .d-edge:last-child{{border-bottom:none}}
.d-edge-dir{{color:#555;font-size:9px;flex-shrink:0;width:12px}}
.d-edge-rel{{font-family:monospace;color:#7ed321;flex-shrink:0;width:78px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.d-edge-node{{color:#4a90d9;cursor:pointer;word-break:break-all;flex:1}}
.d-edge-node:hover{{text-decoration:underline}}
#detail-content .d-conf{{font-size:10px;color:#555}}
/* History nav */
#history-nav{{display:flex;align-items:center;gap:6px;padding:6px 12px;border-top:1px solid #2a2d3a;flex-shrink:0}}
.btn-hist{{padding:4px 10px;border-radius:5px;border:1px solid #3a3d4a;background:#252833;color:#aaa;font-size:11px;cursor:pointer}}
.btn-hist:hover{{background:#2f3244;color:#e0e0e0}}
.btn-hist:disabled{{opacity:0.3;cursor:not-allowed}}
#history-pos{{font-size:10px;color:#444;white-space:nowrap}}
/* Graph canvas */
#cy{{flex:1;background:#0f1117;position:relative}}
/* Navigator */
.cnav-navigator{{position:absolute!important;right:14px!important;bottom:14px!important;width:150px!important;height:100px!important;background:#13151f!important;border:1px solid #2a2d3a!important;border-radius:8px!important;overflow:hidden!important;box-shadow:0 4px 20px rgba(0,0,0,.5)!important;z-index:100!important}}
.cnav-navigator .cnav-minimap{{background:#0f1117!important}}
.cnav-navigator canvas{{background:#0f1117!important}}
.cnav-navigator .cnav-overlay{{background:rgba(74,144,217,.12)!important;border:1px solid #4a90d9!important}}
.cnav-navigator .cnav-collapse,.cnav-navigator .cnav-expand{{display:none!important}}
/* Toast */
#toast{{position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:#252833;border:1px solid #3a3d4a;color:#ccc;padding:6px 16px;border-radius:8px;font-size:12px;opacity:0;transition:opacity 250ms;pointer-events:none;z-index:1000;white-space:nowrap}}
#toast.show{{opacity:1}}
/* Hint bar */
#kb-hint{{position:absolute;bottom:14px;left:14px;font-size:10px;color:#333;pointer-events:none;z-index:10}}
#kb-hint span{{background:#1a1d27;padding:2px 5px;border-radius:3px;border:1px solid #2a2d3a;color:#333;margin-right:3px}}
</style>
</head>
<body>
<div id="topbar">
<h1>{title}</h1>
<input id="search" type="search" placeholder="Search nodes…" autocomplete="off">
<span id="stats-bar">{node_count} nodes · {edge_count} edges</span>
<button id="btn-isolate" class="btn" title="Toggle isolate mode [I]">Isolate</button>
<button id="btn-export" class="btn" title="Export PNG">PNG</button>
<button id="btn-reset" class="btn" title="Reset view">Reset</button>
</div>
<div id="main">
<div id="sidebar">
<div class="filter-section">
<h3>Node types <span class="kbd">T</span></h3>
<div class="filter-scroll" id="type-filters">{type_checkboxes}</div>
</div>
<div class="filter-section">
<h3>Relations <span class="kbd">R</span></h3>
<div class="filter-scroll" id="rel-filters">{rel_checkboxes}</div>
</div>
<div id="hub-section">
<h3>Top hubs</h3>
<div id="hub-list"></div>
</div>
<div id="detail-section">
<h3>Node detail</h3>
<div id="detail-content"><p class="detail-empty">Click a node to see details</p></div>
</div>
<div id="history-nav">
<button id="btn-back" class="btn-hist" disabled>← Back</button>
<span id="history-pos"></span>
<button id="btn-fwd" class="btn-hist" disabled>Forward →</button>
</div>
</div>
<div id="cy"></div>
</div>
<div id="toast"></div>
<div id="kb-hint">
<span>/ search</span><span>Esc clear</span><span>f fit</span><span>+/- zoom</span><span>←→ history</span><span>I isolate</span>
</div>
<script>
(function(){{
"use strict";
window.GRAPH_NAME = "{graph_name_escaped}";
const TOP_HUBS = [{top_hubs_js}];
const rawNodes = [
{nodes_js}
];
const rawEdges = [
{edges_js}
];
function showToast(msg, ms) {{
var t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(function() {{ t.classList.remove('show'); }}, ms || 2000);
}}
var cy = cytoscape({{
container: document.getElementById('cy'),
elements: {{ nodes: rawNodes, edges: rawEdges }},
style: [
{{
selector: 'node',
style: {{
'background-color': 'data(color)',
'label': 'data(label)',
'color': '#ccc',
'font-size': '10px',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '4px',
'text-outline-color': '#0f1117',
'text-outline-width': '2px',
'width': 26,
'height': 26,
'border-width': '2px',
'border-color': 'data(color)',
'border-opacity': 0.5,
'transition-property': 'background-color,border-color,width,height,opacity',
'transition-duration': 100,
}}
}},
{{
selector: 'node:selected',
style: {{ 'width': 38, 'height': 38, 'border-width': '3px', 'border-color': '#fff', 'z-index': 999 }}
}},
{{
selector: 'node.highlighted',
style: {{ 'border-color': '#fff', 'opacity': 1 }}
}},
{{
selector: 'node.faded',
style: {{ 'opacity': 0.1 }}
}},
{{
selector: 'node.search-match',
style: {{ 'border-color': '#ffdd57', 'border-width': '3px', 'z-index': 100 }}
}},
{{
selector: 'node.isolated-root',
style: {{ 'width': 42, 'height': 42, 'border-color': '#4a90d9', 'border-width': '3px', 'z-index': 200 }}
}},
{{
selector: 'edge',
style: {{
'width': 1.2,
'line-color': '#253050',
'target-arrow-color': '#253050',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'label': 'data(relation)',
'font-size': '8px',
'color': '#334',
'text-rotation': 'autorotate',
'text-rotation-anchor': 'end',
'text-margin-x': 3,
'text-outline-color': '#0f1117',
'text-outline-width': '1px',
'opacity': 0.7,
'transition-property': 'line-color,opacity,width',
'transition-duration': 100,
}}
}},
{{
selector: 'edge.highlighted',
style: {{ 'line-color': '#4a90d9', 'target-arrow-color': '#4a90d9', 'opacity': 1, 'width': 2 }}
}},
{{
selector: 'edge.faded',
style: {{ 'opacity': 0.02 }}
}},
],
layout: {{
name: 'cose',
animate: true,
animationDuration: 500,
nodeRepulsion: 8000,
idealEdgeLength: 80,
gravity: 0.25,
numIter: 800,
randomize: true,
fit: true,
padding: 40,
}},
wheelSensitivity: 0.3,
}});
// Navigator
(function(){{
var navReady = function() {{
try {{
var nav = new window.cytoscape_navigator(cy, {{
container: document.getElementById('cy'),
viewLiveFrustum: false,
minZoom: 0.05,
maxZoom: 0.5,
}});
}} catch(e) {{}}
}};
if (window.cytoscape_navigator) {{ navReady(); return; }}
var s = document.createElement('script');
s.src = 'https://unpkg.com/cytoscape-navigator@2.0.0/cytoscape-navigator.js';
s.onload = navReady;
document.head.appendChild(s);
}})();
// History
var hist = [];
var histIdx = -1;
function pushHist(nodeId) {{
hist.splice(histIdx + 1);
hist.push(nodeId);
histIdx = hist.length - 1;
updHist();
}}
function goBack() {{
if (histIdx > 0) {{ histIdx--; focusNode(hist[histIdx], false); updHist(); }}
}}
function goFwd() {{
if (histIdx < hist.length - 1) {{ histIdx++; focusNode(hist[histIdx], false); updHist(); }}
}}
function updHist() {{
document.getElementById('btn-back').disabled = histIdx <= 0;
document.getElementById('btn-fwd').disabled = histIdx >= hist.length - 1;
document.getElementById('history-pos').textContent = histIdx >= 0 ? (histIdx + 1) + '/' + hist.length : '';
}}
// State
var isolateMode = false;
function clearAll() {{
cy.elements().removeClass('highlighted faded search-match isolated-root');
cy.elements().style('display', 'element');
cy.edges().removeClass('rel-hidden');
isolateMode = false;
document.getElementById('btn-isolate').classList.remove('isolating');
}}
function focusNode(nodeId, addHist) {{
if (addHist === undefined) addHist = true;
var node = cy.getElementById(nodeId);
if (!node.length) return;
if (addHist) pushHist(nodeId);
if (isolateMode) {{
node.select();
node.addClass('isolated-root');
var nb = node.neighborhood();
node.addClass('highlighted');
nb.addClass('highlighted');
cy.elements().not(node).not(nb).addClass('faded');
nb.edges().addClass('highlighted');
showDetail(node);
cy.animate({{ fit: {{ eles: node.closedNeighborhood(), padding: 90 }}, duration: 300 }});
}} else {{
clearAll();
node.select();
var nb = node.neighborhood();
node.addClass('highlighted');
node.addClass('isolated-root');
nb.addClass('highlighted');
cy.elements().not(node).not(nb).addClass('faded');
nb.edges().addClass('highlighted');
showDetail(node);
cy.animate({{ fit: {{ eles: node.closedNeighborhood(), padding: 90 }}, duration: 300 }});
}}
document.querySelectorAll('.hub-item').forEach(function(el) {{
el.classList.toggle('active', el.dataset.id === nodeId);
}});
}}
window._focusNode = focusNode;
function showDetail(node) {{
var d = node.data();
var outE = node.outgoers('edge');
var inE = node.incomers('edge');
var edgesHtml = '';
outE.forEach(function(e) {{
if (e.style('display') === 'none') return;
var tgt = e.target().data();
edgesHtml += '<div class="d-edge"><span class="d-edge-dir">→</span><span class="d-edge-rel">' + e.data('relation') + '</span><span class="d-edge-node" onclick="window._focusNode(\'' + tgt.id + '\')">' + tgt.label + '</span></div>';
}});
inE.forEach(function(e) {{
if (e.style('display') === 'none') return;
var src = e.source().data();
edgesHtml += '<div class="d-edge"><span class="d-edge-dir">←</span><span class="d-edge-rel">' + e.data('relation') + '</span><span class="d-edge-node" onclick="window._focusNode(\'' + src.id + '\')">' + src.label + '</span></div>';
}});
var factsHtml = d.facts.length
? d.facts.map(function(f) {{ return '<div class="d-fact">' + f + '</div>'; }}).join('')
: '<span style="color:#444">—</span>';
var aliasesHtml = d.aliases.length ? d.aliases.map(function(a) {{ return '<span class="d-pill">' + a + '</span>'; }}).join('') : '';
var sourcesHtml = d.sources.length ? d.sources.map(function(s) {{ return '<span class="d-pill">' + s + '</span>'; }}).join('') : '';
var notesHtml = d.notes.length ? d.notes.map(function(n) {{ return '<div class="d-note">' + n + '</div>'; }}).join('') : '';
var confHtml = d.confidence ? '<div class="d-conf">Confidence: ' + d.confidence + '</div>' : '';
var degree = outE.length + inE.length;
document.getElementById('detail-content').innerHTML =
'<div class="d-id">' + d.id + '</div>' +
'<span class="d-type" style="background:' + d.color + '">' + d.type + ' · ' + degree + ' conn</span>' +
'<div class="d-name">' + d.label + '</div>' +
(d.desc ? '<div class="d-desc">' + d.desc + '</div>' : '') +
confHtml +
(aliasesHtml ? '<div class="d-section">Aliases</div>' + aliasesHtml : '') +
'<div class="d-section">Facts (' + d.facts.length + ')</div>' + factsHtml +
(notesHtml ? '<div class="d-section">Notes (' + d.notes.length + ')</div>' + notesHtml : '') +
'<div class="d-section">Connections (' + degree + ')</div>' + (edgesHtml || '<span style="color:#444">—</span>') +
(sourcesHtml ? '<div class="d-section">Sources</div>' + sourcesHtml : '');
}}
// Click handlers
cy.on('tap', 'node', function(evt) {{ focusNode(evt.target.id()); }});
cy.on('tap', function(evt) {{
if (evt.target === cy) {{
if (!isolateMode) {{
clearAll();
cy.elements().unselect();
document.getElementById('detail-content').innerHTML = '<p class="detail-empty">Click a node to see details</p>';
}}
}}
}});
// Search
var searchTimeout;
document.getElementById('search').addEventListener('input', function() {{
clearTimeout(searchTimeout);
var q = this.value.trim().toLowerCase();
if (!q) {{
clearAll();
cy.elements().unselect();
return;
}}
searchTimeout = setTimeout(function() {{
clearAll();
cy.nodes().forEach(function(n) {{
var d = n.data();
var matches = d.id.toLowerCase().indexOf(q) !== -1 ||
d.label.toLowerCase().indexOf(q) !== -1 ||
d.aliases.some(function(a) {{ return a.toLowerCase().indexOf(q) !== -1; }}) ||
d.desc.toLowerCase().indexOf(q) !== -1;
if (matches) n.addClass('search-match');
else n.addClass('faded');
}});
var matched = cy.nodes('.search-match');
if (matched.length) cy.animate({{ fit: {{ eles: matched, padding: 80 }}, duration: 300 }});
}}, 180);
}});
// Filters
function applyFilters() {{
var activeTypes = {{}};
document.querySelectorAll('.type-filter:checked').forEach(function(cb) {{ activeTypes[cb.value] = true; }});
var activeRels = {{}};
document.querySelectorAll('.rel-filter:checked').forEach(function(cb) {{ activeRels[cb.value] = true; }});
cy.nodes().forEach(function(n) {{
n.style('display', activeTypes[n.data('type')] ? 'element' : 'none');
}});
cy.edges().forEach(function(e) {{
var srcOk = e.source().style('display') !== 'none';
var tgtOk = e.target().style('display') !== 'none';
var relOk = !!activeRels[e.data('relation')];
e.style('display', srcOk && tgtOk && relOk ? 'element' : 'none');
}});
}}
document.querySelectorAll('.type-filter,.rel-filter').forEach(function(cb) {{
cb.addEventListener('change', applyFilters);
}});
// Isolate
document.getElementById('btn-isolate').addEventListener('click', function() {{
var sel = cy.$(':selected');
if (!sel.length) {{ showToast('Select a node first'); return; }}
isolateMode = !isolateMode;
this.classList.toggle('isolating', isolateMode);
focusNode(sel.id());
}});
// Export PNG
document.getElementById('btn-export').addEventListener('click', function() {{
var png = cy.png({{ bg: '#0f1117', scale: 2, full: true }});
var a = document.createElement('a');
a.href = png;
a.download = (window.GRAPH_NAME || 'graph') + '.png';
a.click();
showToast('PNG exported');
}});
// Reset
function resetAll() {{
clearAll();
cy.elements().style('display', 'element');
document.querySelectorAll('.type-filter,.rel-filter').forEach(function(cb) {{ cb.checked = true; }});
cy.fit(undefined, 40);
cy.nodes().unselect();
document.getElementById('detail-content').innerHTML = '<p class="detail-empty">Click a node to see details</p>';
document.getElementById('search').value = '';
hist.length = 0; histIdx = -1; updHist();
document.querySelectorAll('.hub-item').forEach(function(el) {{ el.classList.remove('active'); }});
}}
document.getElementById('btn-reset').addEventListener('click', resetAll);
document.getElementById('btn-back').addEventListener('click', goBack);
document.getElementById('btn-fwd').addEventListener('click', goFwd);
// Hub list
(function() {{
var list = document.getElementById('hub-list');
TOP_HUBS.forEach(function(hub) {{
var el = document.createElement('div');
el.className = 'hub-item';
el.dataset.id = hub.id;
el.innerHTML = '<span class="hub-dot" style="background:' + hub.color + '"></span><span class="hub-name">' + hub.label + '</span><span class="hub-deg">' + hub.degree + '</span>';
el.addEventListener('click', function() {{ focusNode(hub.id); }});
list.appendChild(el);
}});
}})();
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {{
var tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
switch(e.key) {{
case '/': e.preventDefault(); document.getElementById('search').focus(); break;
case 'Escape': clearAll(); cy.nodes().unselect(); document.getElementById('detail-content').innerHTML = '<p class="detail-empty">Click a node to see details</p>'; isolateMode = false; document.getElementById('btn-isolate').classList.remove('isolating'); break;
case 'f': case 'F': cy.fit(undefined, 40); break;
case '+': case '=': cy.zoom(cy.zoom() * 1.25); break;
case '-': cy.zoom(cy.zoom() / 1.25); break;
case 'ArrowLeft': goBack(); break;
case 'ArrowRight': goFwd(); break;
case 'i': case 'I': document.getElementById('btn-isolate').click(); break;
}}
}});
// Fit on load
cy.ready(function() {{ setTimeout(function() {{ cy.fit(undefined, 40); }}, 600); }});
}})();
</script>
</body>
</html>"#,
title = title,
graph_name_escaped = graph_name_escaped,
node_count = node_count,
edge_count = edge_count,
nodes_js = nodes_js,
edges_js = edges_js,
type_checkboxes = type_checkboxes,
rel_checkboxes = rel_checkboxes,
top_hubs_js = top_hubs_js,
)
}