use super::theme::{Shape, Theme};
use crate::models::{Cardinality, Graph, Kind};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VisData {
pub nodes: Vec<VisNode>,
pub edges: Vec<VisEdge>,
pub groups: Vec<String>,
pub group_colors: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VisColor {
pub background: String,
pub border: String,
pub highlight: VisHighlight,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VisHighlight {
pub background: String,
pub border: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VisFont {
pub color: String,
pub size: u32,
pub face: String,
pub stroke_width: u32,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VisNode {
pub id: u32,
pub label: String,
pub shape: String,
pub margin: u32,
pub cluster_group: Option<String>,
pub color: VisColor,
pub border_width: u32,
pub font: VisFont,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VisEdge {
pub id: String, pub from: u32,
pub to: u32,
pub label: String,
pub color: String,
pub width: f32,
pub dashes: bool,
pub arrows: String,
pub font: VisFont,
}
pub fn produce_vis_data(graph: &Graph) -> VisData {
let mut vis_nodes = Vec::new();
let mut vis_edges = Vec::new();
let mut groups = HashSet::new();
let mut group_colors = HashMap::new();
for n in &graph.nodes {
let group_name = n.function.group.as_ref().map(|g| g.name.clone());
if let Some(ref g) = group_name {
groups.insert(g.clone());
if !group_colors.contains_key(g) {
let (hex, _) = Theme::get_group_color(g);
group_colors.insert(g.clone(), hex);
}
}
let style = Theme::get_node_style(n);
let vis_shape = match style.shape {
Shape::Box => "box",
Shape::Rounded => "box",
Shape::Diamond => "box",
};
vis_nodes.push(VisNode {
id: n.uid,
label: format!(" {} ", n.function.name),
shape: vis_shape.into(),
margin: 10,
cluster_group: group_name.clone(),
color: VisColor {
background: style.fill.clone(),
border: style.border.clone(),
highlight: VisHighlight {
background: style.fill,
border: "#ffffff".into(),
},
},
border_width: style.stroke_width,
font: VisFont {
color: style.text,
size: 14,
face: "sans-serif".into(),
stroke_width: 0,
},
});
}
for e in &graph.edges {
let is_many = e.token.cardinality == Cardinality::Collection;
let t_name = match &e.token.kind {
Kind::Constant(c) => &c.name,
Kind::Variable(v) => &v.name,
Kind::Error(er) => &er.name,
};
let (color, _) = Theme::get_token_color(&e.token.kind);
let edge_id = format!("{}-{}-{}", e.from_node_uid, e.to_node_uid, e.token.uid);
vis_edges.push(VisEdge {
id: edge_id,
from: e.from_node_uid,
to: e.to_node_uid,
label: if is_many {
format!("[{}]", t_name)
} else {
t_name.clone()
},
color: color.into(),
width: if is_many { 5.0 } else { 1.5 },
dashes: matches!(e.token.kind, Kind::Constant(_)),
arrows: "to".into(),
font: VisFont {
color: "#ffffff".into(),
size: 11,
face: "monospace".into(),
stroke_width: 0,
},
});
}
VisData {
nodes: vis_nodes,
edges: vis_edges,
groups: groups.into_iter().collect(),
group_colors,
}
}
pub fn generate_interactive_html(graph: &Graph) -> String {
let data = produce_vis_data(graph);
let nodes_json = serde_json::to_string(&data.nodes).unwrap();
let edges_json = serde_json::to_string(&data.edges).unwrap();
let groups_json = serde_json::to_string(&data.groups).unwrap();
let color_map_json = serde_json::to_string(&data.group_colors).unwrap();
format!(
r#"<!DOCTYPE html>
<html style="color-scheme: dark;">
<head>
<meta charset="utf-8">
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style type="text/css">
body {{ background-color: #0b0e14; color: #e0e0e0; margin: 0; display: flex; font-family: sans-serif; height: 100vh; overflow: hidden; }}
#mynetwork {{ flex-grow: 1; height: 100vh; }}
#resizer {{ width: 6px; cursor: col-resize; background-color: #30363d; transition: background 0.2s; z-index: 10; }}
#resizer:hover {{ background-color: #58a6ff; }}
#config {{ width: 350px; min-width: 250px; height: 100vh; overflow-y: auto; background: #161b22; flex-shrink: 0; display: flex; flex-direction: column; }}
#config-controls {{ flex-grow: 1; }}
.vis-configuration-wrapper {{ color: #e0e0e0 !important; padding: 10px; }}
.vis-config-item {{ background: none !important; border: none !important; }}
.vis-config-label {{ color: #bbb !important; }}
.vis-config-header {{ color: #58a6ff !important; font-weight: bold; margin-top: 10px; border-bottom: 1px solid #333; }}
.vis-network .vis-navigation .vis-button {{ background-color: #21262d; border: 1px solid #444; border-radius: 4px; }}
#options-export {{ padding: 15px; background: #0d1117; border-top: 2px solid #30363d; flex-shrink: 0; }}
#options-export h3 {{ margin-top: 0; font-size: 14px; color: #58a6ff; }}
#options-code {{ background: #161b22; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 11px; max-height: 200px; overflow: auto; white-space: pre-wrap; border: 1px solid #30363d; color: #8b949e; }}
#copy-btn {{ margin-top: 10px; width: 100%; padding: 8px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }}
#copy-btn:hover {{ background: #2ea043; }}
</style>
</head>
<body>
<div id="mynetwork"></div>
<div id="resizer"></div>
<div id="config">
<div id="config-controls"></div>
<div id="options-export">
<h3>Current Options (JSON)</h3>
<div id="options-code">Modify a control to see JSON...</div>
<button id="copy-btn">Copy Options</button>
</div>
</div>
<script type="text/javascript">
const nodes = new vis.DataSet({nodes_json});
const edges = new vis.DataSet({edges_json});
const groups = {groups_json};
const groupColors = {color_map_json};
const container = document.getElementById('mynetwork');
const configContainer = document.getElementById('config');
const configControls = document.getElementById('config-controls');
const optionsCode = document.getElementById('options-code');
const copyBtn = document.getElementById('copy-btn');
const resizer = document.getElementById('resizer');
let isResizing = false;
resizer.addEventListener('mousedown', () => isResizing = true);
document.addEventListener('mousemove', (e) => {{
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
if (newWidth > 200 && newWidth < 900) configContainer.style.width = newWidth + 'px';
}});
document.addEventListener('mouseup', () => isResizing = false);
let lastScrollTop = 0;
configContainer.addEventListener('scroll', () => {{ if (configContainer.scrollTop > 0) lastScrollTop = configContainer.scrollTop; }}, {{passive: true}});
new MutationObserver(() => {{ if (configContainer.scrollTop !== lastScrollTop) configContainer.scrollTop = lastScrollTop; }})
.observe(configControls, {{ childList: true, subtree: true }});
const data = {{ nodes, edges }};
const options = {{
physics: {{ enabled: true, solver: 'forceAtlas2Based', forceAtlas2Based: {{ gravitationalConstant: -100, springLength: 10, avoidOverlap: 1, damping: 0.75 }} }},
interaction: {{ navigationButtons: true, keyboard: true, hover: true }},
configure: {{ enabled: true, container: configControls, showButton: false }}
}};
const network = new vis.Network(container, data, options);
network.on("configChange", (params) => {{ optionsCode.innerText = JSON.stringify(params, null, 2); }});
const clusterBy = (g) => ({{
joinCondition: (n) => n.clusterGroup === g,
clusterNodeProperties: {{ id: 'c:'+g, label: g, shape: 'box', margin: 10, color: {{ background: groupColors[g] || '#fbbf24', border: '#fff' }}, font: {{ color: '#fff', size: 16, face: 'sans-serif', strokeWidth: 0 }} }}
}});
groups.forEach(g => network.cluster(clusterBy(g)));
network.on("click", (p) => {{
if (p.nodes.length > 0) {{
let id = p.nodes[0];
if (network.isCluster(id)) network.openCluster(id);
else {{ let d = nodes.get(id); if (d && d.clusterGroup) network.cluster(clusterBy(d.clusterGroup)); }}
}}
}});
copyBtn.addEventListener('click', () => {{
navigator.clipboard.writeText(optionsCode.innerText).then(() => {{
const originalText = copyBtn.innerText;
copyBtn.innerText = "Copied!";
setTimeout(() => {{ copyBtn.innerText = originalText; }}, 1500);
}});
}});
</script>
</body>
</html>"#
)
}