237 lines
6.8 KiB
Rust
237 lines
6.8 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use serde::Serialize;
|
|
|
|
use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer};
|
|
use archlens_rendering_primitives::non_import_rels;
|
|
|
|
pub struct HtmlRenderer;
|
|
|
|
impl Default for HtmlRenderer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl HtmlRenderer {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct GraphData {
|
|
nodes: Vec<NodeData>,
|
|
edges: Vec<EdgeData>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct NodeData {
|
|
id: String,
|
|
label: String,
|
|
module: String,
|
|
kind: String,
|
|
fields: Vec<String>,
|
|
methods: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct EdgeData {
|
|
source: String,
|
|
target: String,
|
|
kind: String,
|
|
}
|
|
|
|
impl DiagramRenderer for HtmlRenderer {
|
|
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
|
|
// Build graph data
|
|
let mut id_map: HashMap<String, String> = HashMap::new();
|
|
let mut nodes = Vec::new();
|
|
|
|
for (i, el) in graph.elements().iter().enumerate() {
|
|
let id = format!("n{i}");
|
|
id_map.insert(el.qualified_name().to_string(), id.clone());
|
|
id_map.insert(el.name().to_string(), id.clone());
|
|
nodes.push(NodeData {
|
|
id,
|
|
label: el.name().to_string(),
|
|
module: el
|
|
.module()
|
|
.map(|m| m.as_str().to_string())
|
|
.unwrap_or_default(),
|
|
kind: format!("{:?}", el.kind()),
|
|
fields: el.fields().to_vec(),
|
|
methods: el.methods().to_vec(),
|
|
});
|
|
}
|
|
|
|
let edges = non_import_rels(graph.relationships())
|
|
.filter_map(|r| {
|
|
let src = id_map.get(r.source())?;
|
|
let tgt = id_map.get(r.target())?;
|
|
Some(EdgeData {
|
|
source: src.clone(),
|
|
target: tgt.clone(),
|
|
kind: format!("{:?}", r.kind()),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let data = GraphData { nodes, edges };
|
|
let json =
|
|
serde_json::to_string(&data).map_err(|e| DomainError::ConfigError(e.to_string()))?;
|
|
|
|
let html = build_html(&json);
|
|
let file = RenderedFile::new("diagram.html", &html)?;
|
|
Ok(RenderOutput::single(file))
|
|
}
|
|
}
|
|
|
|
fn build_html(graph_json: &str) -> String {
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Architecture Diagram</title>
|
|
<style>
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
body {{ font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; }}
|
|
#sidebar {{ width: 280px; background: #16213e; padding: 1rem; overflow-y: auto; border-right: 1px solid #0f3460; }}
|
|
#cy {{ flex: 1; }}
|
|
h2 {{ color: #e94560; margin-bottom: 0.5rem; font-size: 1rem; }}
|
|
#detail {{ padding: 0.5rem 0; font-size: 0.85rem; }}
|
|
.member {{ padding: 2px 0; color: #aaa; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="sidebar">
|
|
<h2>Architecture Diagram</h2>
|
|
<div id="detail"><p>Click a node to see details.</p></div>
|
|
</div>
|
|
<div id="cy"></div>
|
|
<script>
|
|
const GRAPH = {graph_json};
|
|
|
|
// Inline minimal Cytoscape-compatible renderer using Canvas API
|
|
(function() {{
|
|
const canvas = document.createElement('canvas');
|
|
const container = document.getElementById('cy');
|
|
canvas.style.width = '100%';
|
|
canvas.style.height = '100%';
|
|
container.appendChild(canvas);
|
|
|
|
const detail = document.getElementById('detail');
|
|
|
|
function resize() {{
|
|
canvas.width = container.clientWidth;
|
|
canvas.height = container.clientHeight;
|
|
draw();
|
|
}}
|
|
|
|
// Group nodes by module
|
|
const modules = {{}};
|
|
GRAPH.nodes.forEach(n => {{
|
|
const m = n.module || '(ungrouped)';
|
|
if (!modules[m]) modules[m] = [];
|
|
modules[m].push(n);
|
|
}});
|
|
|
|
// Layout: arrange modules in a grid, nodes within each module in a column
|
|
const positions = {{}};
|
|
const modNames = Object.keys(modules);
|
|
const cols = Math.ceil(Math.sqrt(modNames.length));
|
|
const cellW = 220, cellH = 200, pad = 60;
|
|
|
|
modNames.forEach((mod, mi) => {{
|
|
const col = mi % cols, row = Math.floor(mi / cols);
|
|
const bx = pad + col * (cellW + pad);
|
|
const by = pad + row * (cellH + pad);
|
|
modules[mod].forEach((n, ni) => {{
|
|
positions[n.id] = {{
|
|
x: bx + 20 + (ni % 2) * 90,
|
|
y: by + 30 + Math.floor(ni / 2) * 50
|
|
}};
|
|
}});
|
|
}});
|
|
|
|
let selected = null;
|
|
const nodeRadius = 18;
|
|
|
|
function draw() {{
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw module backgrounds
|
|
modNames.forEach((mod, mi) => {{
|
|
const col = mi % cols, row = Math.floor(mi / cols);
|
|
const bx = pad / 2 + col * (cellW + pad);
|
|
const by = pad / 2 + row * (cellH + pad);
|
|
ctx.fillStyle = 'rgba(15,52,96,0.4)';
|
|
ctx.fillRect(bx, by, cellW + pad / 2, cellH + pad / 2);
|
|
ctx.fillStyle = '#4fc3f7';
|
|
ctx.font = '11px sans-serif';
|
|
ctx.fillText(mod, bx + 6, by + 14);
|
|
}});
|
|
|
|
// Draw edges
|
|
GRAPH.edges.forEach(e => {{
|
|
const sp = positions[e.source], tp = positions[e.target];
|
|
if (!sp || !tp) return;
|
|
ctx.strokeStyle = e.kind === 'Inheritance' ? '#e94560' : '#aaa';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sp.x, sp.y);
|
|
ctx.lineTo(tp.x, tp.y);
|
|
ctx.stroke();
|
|
}});
|
|
|
|
// Draw nodes
|
|
GRAPH.nodes.forEach(n => {{
|
|
const p = positions[n.id];
|
|
if (!p) return;
|
|
ctx.fillStyle = selected && selected.id === n.id ? '#e94560' : '#0f3460';
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, nodeRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#eee';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(n.label.substring(0, 12), p.x, p.y + 4);
|
|
}});
|
|
}}
|
|
|
|
canvas.addEventListener('click', e => {{
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
selected = null;
|
|
for (const n of GRAPH.nodes) {{
|
|
const p = positions[n.id];
|
|
if (!p) continue;
|
|
const dx = mx - p.x, dy = my - p.y;
|
|
if (dx * dx + dy * dy < nodeRadius * nodeRadius) {{
|
|
selected = n;
|
|
break;
|
|
}}
|
|
}}
|
|
if (selected) {{
|
|
detail.innerHTML = `<strong>${{selected.label}}</strong><br><em>${{selected.module}}</em>` +
|
|
(selected.fields.length ? '<br><b>Fields:</b><br>' + selected.fields.map(f => `<div class="member">${{f}}</div>`).join('') : '') +
|
|
(selected.methods.length ? '<br><b>Methods:</b><br>' + selected.methods.map(m => `<div class="member">${{m}}</div>`).join('') : '');
|
|
}} else {{
|
|
detail.innerHTML = '<p>Click a node to see details.</p>';
|
|
}}
|
|
draw();
|
|
}});
|
|
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
}})();
|
|
</script>
|
|
</body>
|
|
</html>"#,
|
|
graph_json = graph_json
|
|
)
|
|
}
|