use std::collections::HashSet;
use std::fmt::Write;
use crate::parser::ast::*;
use crate::visualize::{Cat, categorize, icon, is_vtex, is_audio};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum AstStyle { Technical, Artwork, Ling }
impl AstStyle {
pub fn slug(self) -> &'static str {
match self { AstStyle::Technical => "technical", AstStyle::Artwork => "artwork", AstStyle::Ling => "ling" }
}
}
pub fn render(style: AstStyle, project: &str, files: &[(String, Program)]) -> String {
let model = Project::analyze(project, files);
match style {
AstStyle::Technical => render_technical(&model),
AstStyle::Artwork => render_artwork(&model),
AstStyle::Ling => render_ling(&model),
}
}
struct Call { name: String, cat: Cat, count: usize }
struct Func {
name: String,
params: Vec<String>,
calls: Vec<Call>,
has_loop: bool,
is_entry: bool,
vtex: usize,
audio: usize,
}
struct FileScope {
label: String,
funcs: Vec<Func>,
globals: Vec<(String, String)>,
uses: Vec<String>,
}
struct Project {
name: String,
files: Vec<FileScope>,
fn_names: HashSet<String>,
}
const ENTRY_NAMES: &[&str] = &[
"start", "main", "启", "เริ่ม", "시작", "始め", "始", "начать", "начало",
"inicio", "comenzar", "début", "commencer", "anfang", "starten", "início",
];
fn is_entry(name: &str) -> bool { ENTRY_NAMES.contains(&name) }
impl Project {
fn analyze(name: &str, files: &[(String, Program)]) -> Self {
let mut fn_names = HashSet::new();
for (_, prog) in files {
collect_names(&prog.items, &mut fn_names);
}
let mut scopes = Vec::new();
for (label, prog) in files {
let mut funcs = Vec::new();
let mut globals = Vec::new();
let mut uses = Vec::new();
collect_scope(&prog.items, "", &mut funcs, &mut globals, &mut uses);
if funcs.is_empty() && globals.is_empty() && uses.is_empty() { continue; }
scopes.push(FileScope {
label: short_label(label),
funcs, globals, uses,
});
}
Project { name: name.to_string(), files: scopes, fn_names }
}
fn total_funcs(&self) -> usize { self.files.iter().map(|f| f.funcs.len()).sum() }
}
fn short_label(path: &str) -> String {
let p = path.replace('\\', "/");
p.rsplit('/').next().unwrap_or(&p).to_string()
}
fn collect_names(items: &[Item], out: &mut HashSet<String>) {
for it in items {
match it {
Item::Fn(f) => { out.insert(f.name.clone()); }
Item::Bind(name, Expr::Do(_)) => { out.insert(name.clone()); }
Item::Mod(_, body) => collect_names(body, out),
_ => {}
}
}
}
fn collect_scope(items: &[Item], ns: &str, funcs: &mut Vec<Func>,
globals: &mut Vec<(String, String)>, uses: &mut Vec<String>) {
let q = |n: &str| if ns.is_empty() { n.to_string() } else { format!("{ns}::{n}") };
for it in items {
match it {
Item::Fn(f) => funcs.push(build_func(q(&f.name), f.params.clone(), &f.body, is_entry(&f.name))),
Item::Bind(name, Expr::Do(body)) => {
funcs.push(build_func(q(name), vec![], body, is_entry(name)));
}
Item::Bind(name, expr) => globals.push((q(name), value_repr(expr))),
Item::Mod(name, body) => collect_scope(body, &q(name), funcs, globals, uses),
Item::Use { path, alias } => {
let a = alias.as_deref().map(|x| format!(" as {x}")).unwrap_or_default();
uses.push(format!("{path}{a}"));
}
Item::TypeAlias(n, t) => globals.push((q(n), format!("type {t}"))),
}
}
}
fn value_repr(e: &Expr) -> String {
match e {
Expr::Number(n) => if n.fract() == 0.0 { format!("{}", *n as i64) } else { format!("{n:.2}") },
Expr::Str(_) => "\"…\"".into(),
Expr::Bool(b) => format!("{b}"),
Expr::Array(_) => "[…]".into(),
_ => "…".into(),
}
}
fn build_func(name: String, params: Vec<String>, body: &[Stmt], is_entry: bool) -> Func {
let mut raw = Vec::new();
let mut has_loop = false;
walk_stmts(body, &mut raw, &mut has_loop);
let calls = aggregate(raw);
let vtex = calls.iter().filter(|c| is_vtex(c.cat)).map(|c| c.count).sum();
let audio = calls.iter().filter(|c| is_audio(c.cat)).map(|c| c.count).sum();
Func { name, params, calls, has_loop, is_entry, vtex, audio }
}
fn aggregate(raw: Vec<(String, Cat)>) -> Vec<Call> {
let mut out: Vec<Call> = Vec::new();
for (name, cat) in raw {
if let Some(l) = out.last_mut() { if l.name == name { l.count += 1; continue; } }
out.push(Call { name, cat, count: 1 });
}
out
}
fn walk_stmts(stmts: &[Stmt], out: &mut Vec<(String, Cat)>, lp: &mut bool) {
for s in stmts {
match s { Stmt::Expr(e) | Stmt::Return(e) | Stmt::Bind(_, e) => walk_expr(e, out, lp) }
}
}
fn walk_expr(e: &Expr, out: &mut Vec<(String, Cat)>, lp: &mut bool) {
match e {
Expr::Call(func, args) => {
let name = match func.as_ref() {
Expr::Ident(n) => Some(n.clone()),
Expr::Path(segs) => segs.last().cloned(),
_ => { walk_expr(func, out, lp); None }
};
if let Some(n) = name { let cat = categorize(&n); out.push((n, cat)); }
for a in args { walk_expr(a, out, lp); }
}
Expr::MethodCall { receiver, method, args } => {
walk_expr(receiver, out, lp);
out.push((method.clone(), categorize(method)));
for a in args { walk_expr(a, out, lp); }
}
Expr::While { cond, body } => { *lp = true; walk_expr(cond, out, lp); walk_stmts(body, out, lp); }
Expr::For { iter, body, .. } => { *lp = true; walk_expr(iter, out, lp); walk_stmts(body, out, lp); }
Expr::Do(ss) => walk_stmts(ss, out, lp),
Expr::If { cond, then, elseifs, else_body } => {
walk_expr(cond, out, lp);
walk_stmts(then, out, lp);
for (c, b) in elseifs { walk_expr(c, out, lp); walk_stmts(b, out, lp); }
if let Some(b) = else_body { walk_stmts(b, out, lp); }
}
Expr::Match(scrut, arms) => {
walk_expr(scrut, out, lp);
for a in arms { walk_expr(&a.body, out, lp); }
}
Expr::BinOp(_, a, b) | Expr::Range(a, b) | Expr::Index(a, b) => {
walk_expr(a, out, lp); walk_expr(b, out, lp);
}
Expr::Ref(x) | Expr::Await(x) => walk_expr(x, out, lp),
Expr::Closure(_, body) => walk_expr(body, out, lp),
Expr::Array(es) => for a in es { walk_expr(a, out, lp); },
_ => {}
}
}
fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") }
fn f(v: f32) -> String { format!("{v:.2}") }
fn svg_doc(w: f32, h: f32, bg_body: &str, body: &str) -> String {
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{win:.3}in" height="{hin:.3}in" viewBox="0 0 {w} {h}" preserveAspectRatio="xMidYMid meet" style="font-family:'JetBrains Mono','Fira Code',monospace,sans-serif">
{bg_body}{body}
</svg>"##,
win = w / 300.0, hin = h / 300.0, w = f(w), h = f(h),
)
}
fn star_points(cx: f32, cy: f32, ro: f32, ri: f32, sides: usize, rot: f32) -> String {
let n = sides.max(2);
(0..n * 2).map(|i| {
let a = rot + i as f32 * std::f32::consts::PI / n as f32;
let r = if i % 2 == 0 { ro } else { ri };
format!("{},{}", f(cx + r * a.cos()), f(cy + r * a.sin()))
}).collect::<Vec<_>>().join(" ")
}
fn ngon_points(cx: f32, cy: f32, r: f32, sides: usize, rot: f32) -> String {
let n = sides.max(3);
(0..n).map(|i| {
let a = rot - std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::TAU / n as f32;
format!("{},{}", f(cx + r * a.cos()), f(cy + r * a.sin()))
}).collect::<Vec<_>>().join(" ")
}
fn hash(s: &str) -> u64 {
let mut h = 1469598103934665603u64;
for b in s.bytes() { h ^= b as u64; h = h.wrapping_mul(1099511628211); }
h
}
fn frand(seed: u64, i: u64) -> f32 {
let mut x = seed ^ i.wrapping_mul(0x9E3779B97F4A7C15);
x ^= x >> 30; x = x.wrapping_mul(0xBF58476D1CE4E5B9);
x ^= x >> 27; x = x.wrapping_mul(0x94D049BB133111EB);
x ^= x >> 31;
(x as f64 / u64::MAX as f64) as f32
}
fn shape_sides(cat: Cat, name: &str) -> usize {
let base = match cat {
Cat::Star | Cat::Yantra => 5,
Cat::Flower | Cat::Chakra => 6,
Cat::Lotus => 8,
Cat::Present => 3,
Cat::Fill | Cat::Grid => 4,
Cat::Rain => 7,
_ => 0,
};
if base != 0 { base } else { 3 + (hash(name) % 6) as usize }
}
const BG: &str = "#0b0b1a";
fn bg_rect(w: f32, h: f32, fill: &str) -> String {
format!(r##"<rect x="0" y="0" width="{}" height="{}" fill="{}"/>"##, f(w), f(h), fill)
}
const T_MARGIN: f32 = 36.0;
const T_TITLE_H: f32 = 96.0;
const T_CARD_W: f32 = 360.0;
const T_CARD_GAP: f32 = 16.0;
const T_COLS: usize = 3;
const T_FILE_HDR: f32 = 40.0;
const T_ICON: f32 = 24.0;
const T_ICONS_ROW: usize = 10;
fn t_card_h(fc: &Func) -> f32 {
let rows = (fc.calls.len() + T_ICONS_ROW - 1).max(1) / T_ICONS_ROW + 1;
24.0 * 2.0 + 30.0 + 18.0 + rows as f32 * (T_ICON + 5.0) + 6.0
}
fn render_technical(p: &Project) -> String {
let content_w = T_COLS as f32 * (T_CARD_W + T_CARD_GAP) - T_CARD_GAP;
let w = content_w + T_MARGIN * 2.0;
struct Placed<'a> { fc: &'a Func, x: f32, y: f32, h: f32 }
let mut placed: Vec<Placed> = Vec::new();
let mut centers: std::collections::HashMap<String, (f32, f32)> = std::collections::HashMap::new();
let mut file_bands: Vec<(String, usize, f32, f32)> = Vec::new();
let mut y = T_TITLE_H + 10.0;
for fs in &p.files {
let band_top = y;
y += T_FILE_HDR;
let mut col_y = [y; T_COLS];
for fc in &fs.funcs {
let h = t_card_h(fc);
let (col, cy) = col_y.iter().enumerate()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap()).map(|(i, &v)| (i, v)).unwrap();
let x = T_MARGIN + col as f32 * (T_CARD_W + T_CARD_GAP);
centers.insert(fc.name.clone(), (x + T_CARD_W / 2.0, cy + h / 2.0));
placed.push(Placed { fc, x, y: cy, h });
col_y[col] = cy + h + T_CARD_GAP;
}
let band_bottom = col_y.iter().cloned().fold(y, f32::max);
let extra = if fs.globals.is_empty() && fs.uses.is_empty() { 0.0 } else { 26.0 };
file_bands.push((fs.label.clone(), fs.globals.len() + fs.uses.len(), band_top, band_bottom - band_top + extra));
y = band_bottom + extra + 24.0;
}
let h = y + T_MARGIN;
let mut body = String::new();
body.push_str(DEFS_T);
let _ = write!(body, r##"<rect width="{}" height="{}" fill="url(#tgrid)" opacity="0.06"/>"##, f(w), f(h));
let tf = p.total_funcs();
let nfiles = p.files.len();
let _ = write!(body,
r##"<text x="{}" y="44" fill="#ffd700" font-size="11" font-weight="bold" letter-spacing="3" opacity="0.7">LING · AST · TECHNICAL</text>
<text x="{}" y="78" fill="#d8d8ff" font-size="30" font-weight="bold">{}</text>
<text x="{}" y="78" fill="#52528a" font-size="13" text-anchor="end">{} files · {} fns</text>"##,
f(T_MARGIN), f(T_MARGIN), esc(&p.name), f(w - T_MARGIN), nfiles, tf);
for pl in &placed {
let (x0, y0) = *centers.get(&pl.fc.name).unwrap();
for c in &pl.fc.calls {
if !p.fn_names.contains(&c.name) { continue; }
if let Some(&(x1, y1)) = centers.get(&c.name) {
if (x0 - x1).abs() < 0.5 && (y0 - y1).abs() < 0.5 { continue; }
let mx = (x0 + x1) / 2.0;
let _ = write!(body,
r##"<path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{}" stroke-width="1.1" opacity="0.22"/>"##,
f(x0), f(y0), f(mx), f(y0), f(mx), f(y1), f(x1), f(y1), c.cat.color());
}
}
}
for (label, _n, by, bh) in &file_bands {
let _ = write!(body,
r##"<rect x="{}" y="{}" width="{}" height="{}" rx="12" fill="#10102a" opacity="0.45" stroke="#22225a" stroke-width="1"/>
<text x="{}" y="{}" fill="#8be9fd" font-size="14" font-weight="bold">◈ {}</text>"##,
f(T_MARGIN - 12.0), f(*by), f(content_w + 24.0), f(*bh),
f(T_MARGIN), f(by + 26.0), esc(label));
}
for pl in &placed { body.push_str(&t_card(pl.fc, pl.x, pl.y, pl.h)); }
for (fs, (_, _, by, bh)) in p.files.iter().zip(file_bands.iter()) {
if fs.globals.is_empty() && fs.uses.is_empty() { continue; }
let fy = by + bh - 8.0;
let mut parts: Vec<String> = fs.globals.iter().map(|(n, v)| format!("◇ {n}={v}")).collect();
parts.extend(fs.uses.iter().map(|u| format!("use {u}")));
let _ = write!(body,
r##"<text x="{}" y="{}" fill="#52528a" font-size="10">{}</text>"##,
f(T_MARGIN), f(fy), esc(&parts.join(" ")));
}
svg_doc(w, h, &bg_rect(w, h, BG), &body)
}
const DEFS_T: &str = r##"<defs>
<filter id="glow-g" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur in="SourceGraphic" stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<pattern id="tgrid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M 40 0 L 0 0 0 40" fill="none" stroke="#ffffff" stroke-width="0.4"/></pattern>
</defs>"##;
fn t_card(fc: &Func, x: f32, y: f32, h: f32) -> String {
let dominant = fc.calls.iter().max_by_key(|c| c.count).map(|c| c.cat.color()).unwrap_or("#6ab0f5");
let border = if fc.is_entry { "#ffd700" } else { dominant };
let bw = if fc.is_entry { 2.5 } else { 1.2 };
let glow = if fc.is_entry { r##" filter="url(#glow-g)""## } else { "" };
let mut s = String::new();
let _ = write!(s,
r##"<rect x="{}" y="{}" width="{}" height="{}" rx="8" fill="#13132e" stroke="{}" stroke-width="{}"{}/>
<rect x="{}" y="{}" width="4" height="{}" rx="2" fill="{}" opacity="0.7"/>"##,
f(x), f(y), f(T_CARD_W), f(h), border, bw, glow,
f(x + 2.0), f(y + 2.0), f(h - 4.0), border);
let name_y = y + 26.0;
let badge = if fc.is_entry {
format!(r##"<text x="{}" y="{}" fill="#ffd700" font-size="9" font-weight="bold" text-anchor="end" opacity="0.85">⬡ ENTRY</text>"##, f(x + T_CARD_W - 12.0), f(name_y))
} else { String::new() };
let _ = write!(s, r##"<text x="{}" y="{}" fill="{}" font-size="14" font-weight="bold">{}</text>{}"##,
f(x + 16.0), f(name_y), if fc.is_entry { "#ffd700" } else { "#d0d0f0" }, esc(&fc.name), badge);
let stats_y = name_y + 18.0;
let params = if fc.params.is_empty() { String::new() } else { format!("({})", fc.params.join(", ")) };
let mut st = Vec::new();
if fc.vtex > 0 { st.push(format!("{} vtex", fc.vtex)); }
if fc.audio > 0 { st.push(format!("{} audio", fc.audio)); }
if fc.has_loop { st.push("↺ loop".into()); }
let _ = write!(s,
r##"<text x="{}" y="{}" fill="#52528a" font-size="10">{}</text>
<text x="{}" y="{}" fill="#52528a" font-size="10" text-anchor="end">{}</text>
<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#22225a" stroke-width="1"/>"##,
f(x + 16.0), f(stats_y), esc(¶ms),
f(x + T_CARD_W - 12.0), f(stats_y), esc(&st.join(" · ")),
f(x + 12.0), f(stats_y + 6.0), f(x + T_CARD_W - 12.0), f(stats_y + 6.0));
let iy0 = stats_y + 6.0 + 5.0 + T_ICON / 2.0;
let ix0 = x + 16.0 + T_ICON / 2.0;
let ir = T_ICON / 2.0 * 0.82;
for (i, c) in fc.calls.iter().enumerate() {
let row = i / T_ICONS_ROW; let col = i % T_ICONS_ROW;
let ix = ix0 + col as f32 * (T_ICON + 5.0);
let iy = iy0 + row as f32 * (T_ICON + 5.0);
let _ = write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" rx="3" fill="{}" opacity="0.12"/>"##,
f(ix - T_ICON / 2.0), f(iy - T_ICON / 2.0), f(T_ICON), f(T_ICON), c.cat.color());
s.push_str(&icon(c.cat, ix, iy, ir));
if c.count > 1 {
let _ = write!(s, r##"<rect x="{}" y="{}" width="13" height="10" rx="3" fill="{}" opacity="0.9"/>
<text x="{}" y="{}" fill="#0a0a1a" font-size="8" font-weight="bold" text-anchor="middle">{}</text>"##,
f(ix + ir - 2.0), f(iy - ir - 1.0), c.cat.color(),
f(ix + ir + 4.5), f(iy - ir + 7.0), c.count);
}
}
s
}
const A_CELL: f32 = 620.0;
fn render_artwork(p: &Project) -> String {
let nf = p.files.len().max(1);
let cols = (nf as f32).sqrt().ceil() as usize;
let rows = (nf + cols - 1) / cols;
let w = cols as f32 * A_CELL;
let h = rows as f32 * A_CELL;
let mut body = String::new();
body.push_str(DEFS_A);
let _ = write!(body, r##"<rect width="{}" height="{}" fill="url(#agrad)"/>"##, f(w), f(h));
let seed = hash(&p.name);
for i in 0..7u64 {
let cx = frand(seed, i) * w;
let cy = frand(seed, i + 100) * h;
let r = 120.0 + frand(seed, i + 200) * 260.0;
let sides = 3 + (i % 5) as usize;
let hue = (i * 47) % 360;
let _ = write!(body, r##"<polygon points="{}" fill="hsl({},55%,55%)" opacity="0.05"/>"##,
ngon_points(cx, cy, r, sides, frand(seed, i + 300) * 6.28), hue);
}
let mut centroids: Vec<(f32, f32)> = Vec::new();
for (fi, fs) in p.files.iter().enumerate() {
let gx = (fi % cols) as f32 * A_CELL + A_CELL / 2.0;
let gy = (fi / cols) as f32 * A_CELL + A_CELL / 2.0;
centroids.push((gx, gy));
let fseed = hash(&fs.label);
let ga = 2.399963f32; for (qi, fc) in fs.funcs.iter().enumerate() {
let rr = 36.0 + 150.0 * ((qi as f32 + 0.5) / fs.funcs.len().max(1) as f32).sqrt();
let na = qi as f32 * ga;
let fx = gx + rr * na.cos();
let fy = gy + rr * na.sin();
let _ = write!(body, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#ffffff" stroke-width="1" opacity="0.06"/>"##,
f(gx), f(gy), f(fx), f(fy));
let shapes = fc.calls.iter().map(|c| c.count).sum::<usize>().max(1).min(14);
let fnseed = hash(&fc.name) ^ fseed;
let pal: Vec<&str> = if fc.calls.is_empty() { vec!["#6ab0f5"] } else { fc.calls.iter().map(|c| c.cat.color()).collect() };
for si in 0..shapes {
let ang = frand(fnseed, si as u64) * 6.2831853;
let dist = frand(fnseed, si as u64 + 7) * 30.0;
let sx = fx + dist * ang.cos();
let sy = fy + dist * ang.sin();
let ro = 14.0 + frand(fnseed, si as u64 + 11) * 34.0 + if fc.is_entry { 16.0 } else { 0.0 };
let col = pal[si % pal.len()];
let cat = fc.calls.get(si % fc.calls.len().max(1)).map(|c| c.cat).unwrap_or(Cat::User);
let sides = shape_sides(cat, &fc.name);
let rot = frand(fnseed, si as u64 + 23) * 6.2831853;
let op = 0.28 + frand(fnseed, si as u64 + 31) * 0.30;
let pts = if si % 2 == 0 { star_points(sx, sy, ro, ro * 0.45, sides, rot) }
else { ngon_points(sx, sy, ro, sides, rot) };
let _ = write!(body, r##"<polygon points="{}" fill="{}" opacity="{:.2}" stroke="{}" stroke-width="0.6" stroke-opacity="0.4"/>"##,
pts, col, op, col);
}
if fc.is_entry {
let _ = write!(body, r##"<circle cx="{}" cy="{}" r="9" fill="#ffd700" opacity="0.9"/>"##, f(fx), f(fy));
}
}
}
for (i, &(x0, y0)) in centroids.iter().enumerate() {
for &(x1, y1) in centroids.iter().skip(i + 1) {
let mx = (x0 + x1) / 2.0; let my = (y0 + y1) / 2.0 - 60.0;
let _ = write!(body, r##"<path d="M {},{} Q {},{} {},{}" fill="none" stroke="#ffffff" stroke-width="0.8" opacity="0.05"/>"##,
f(x0), f(y0), f(mx), f(my), f(x1), f(y1));
}
}
svg_doc(w, h, "", &body)
}
const DEFS_A: &str = r##"<defs>
<radialGradient id="agrad" cx="50%" cy="42%" r="75%">
<stop offset="0%" stop-color="#171733"/><stop offset="55%" stop-color="#0c0c1e"/><stop offset="100%" stop-color="#050510"/>
</radialGradient>
</defs>"##;
const L_MARGIN: f32 = 40.0;
const L_TILE_W: f32 = 188.0;
const L_TILE_H: f32 = 84.0;
const L_TILE_GAP: f32 = 16.0;
const L_PANEL_PAD: f32 = 22.0;
const L_TITLE_H: f32 = 92.0;
fn render_ling(p: &Project) -> String {
let max_funcs = p.files.iter().map(|f| f.funcs.len()).max().unwrap_or(1).max(1);
let tiles_per_row = ((max_funcs as f32).sqrt().ceil() as usize).clamp(2, 6);
let panel_w = L_PANEL_PAD * 2.0 + tiles_per_row as f32 * (L_TILE_W + L_TILE_GAP) - L_TILE_GAP;
let w = panel_w + L_MARGIN * 2.0;
let mut panels: Vec<(usize, f32, f32)> = Vec::new(); let mut y = L_TITLE_H + 12.0;
for (fi, fs) in p.files.iter().enumerate() {
let n = fs.funcs.len().max(1);
let rows = (n + tiles_per_row - 1) / tiles_per_row;
let body_h = rows as f32 * (L_TILE_H + L_TILE_GAP) - L_TILE_GAP;
let glob_h = if fs.globals.is_empty() && fs.uses.is_empty() { 0.0 } else { 30.0 };
let ph = 44.0 + body_h + glob_h + L_PANEL_PAD;
panels.push((fi, y, ph));
y += ph + 26.0;
}
let h = y + L_MARGIN;
let mut body = String::new();
body.push_str(DEFS_L);
let _ = write!(body,
r##"<text x="{}" y="42" fill="#ffd700" font-size="11" font-weight="bold" letter-spacing="3" opacity="0.75">灵 · LING · AST</text>
<text x="{}" y="78" fill="#e6e6ff" font-size="30" font-weight="bold">{}</text>
<text x="{}" y="78" fill="#6a6aa0" font-size="13" text-anchor="end">{} files · {} fns</text>"##,
f(L_MARGIN), f(L_MARGIN), esc(&p.name), f(w - L_MARGIN), p.files.len(), p.total_funcs());
for (fi, py, ph) in &panels {
let fs = &p.files[*fi];
let px = L_MARGIN;
let _ = write!(body,
r##"<rect x="{}" y="{}" width="{}" height="{}" rx="16" fill="#101028" stroke="#26264e" stroke-width="1.2"/>
<rect x="{}" y="{}" width="{}" height="34" rx="16" fill="#16163a"/>
<text x="{}" y="{}" fill="#9bd8ff" font-size="15" font-weight="bold">▦ {}</text>
<text x="{}" y="{}" fill="#6a6aa0" font-size="11" text-anchor="end">{} fns</text>"##,
f(px), f(*py), f(panel_w), f(*ph),
f(px), f(*py), f(panel_w),
f(px + 16.0), f(py + 23.0), esc(&fs.label),
f(px + panel_w - 14.0), f(py + 23.0), fs.funcs.len());
let t0y = py + 44.0;
for (i, fc) in fs.funcs.iter().enumerate() {
let row = i / tiles_per_row; let col = i % tiles_per_row;
let tx = px + L_PANEL_PAD + col as f32 * (L_TILE_W + L_TILE_GAP);
let ty = t0y + row as f32 * (L_TILE_H + L_TILE_GAP);
body.push_str(&l_tile(fc, tx, ty));
}
if !fs.globals.is_empty() || !fs.uses.is_empty() {
let rows = (fs.funcs.len().max(1) + tiles_per_row - 1) / tiles_per_row;
let gy = t0y + rows as f32 * (L_TILE_H + L_TILE_GAP) + 6.0;
let mut parts: Vec<String> = fs.globals.iter().map(|(n, v)| format!("◇ {n} = {v}")).collect();
parts.extend(fs.uses.iter().map(|u| format!("⇥ use {u}")));
let _ = write!(body, r##"<text x="{}" y="{}" fill="#6a6aa0" font-size="11">{}</text>"##,
f(px + L_PANEL_PAD), f(gy), esc(&parts.join(" ")));
}
}
svg_doc(w, h, &bg_rect(w, h, "#08081a"), &body)
}
const DEFS_L: &str = r##"<defs>
<filter id="tile-sh" x="-20%" y="-20%" width="140%" height="160%"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.45"/></filter>
</defs>"##;
fn l_tile(fc: &Func, x: f32, y: f32) -> String {
let dom = fc.calls.iter().max_by_key(|c| c.count).map(|c| c.cat).unwrap_or(Cat::User);
let col = if fc.is_entry { "#ffd700" } else { dom.color() };
let mut s = String::new();
let _ = write!(s,
r##"<rect x="{}" y="{}" width="{}" height="{}" rx="12" fill="#191940" stroke="{}" stroke-width="{}" filter="url(#tile-sh)"/>
<rect x="{}" y="{}" width="{}" height="7" rx="3" fill="{}"/>"##,
f(x), f(y), f(L_TILE_W), f(L_TILE_H), col, if fc.is_entry { 2.4 } else { 1.2 },
f(x + 10.0), f(y + 12.0), f(L_TILE_W - 20.0), col);
let name = if fc.name.chars().count() > 20 { format!("{}…", fc.name.chars().take(19).collect::<String>()) } else { fc.name.clone() };
let _ = write!(s, r##"<text x="{}" y="{}" fill="{}" font-size="14" font-weight="bold">{}{}</text>"##,
f(x + 12.0), f(y + 38.0), if fc.is_entry { "#ffd700" } else { "#e0e0ff" },
if fc.is_entry { "⬡ " } else { "" }, esc(&name));
let mut seen = HashSet::new();
let mut dots: Vec<Cat> = Vec::new();
for c in &fc.calls { if seen.insert(c.cat) { dots.push(c.cat); } }
for (i, c) in dots.iter().take(7).enumerate() {
let _ = write!(s, r##"<circle cx="{}" cy="{}" r="4.5" fill="{}"/>"##,
f(x + 16.0 + i as f32 * 13.0), f(y + 56.0), c.color());
}
let mut st = Vec::new();
if !fc.params.is_empty() { st.push(format!("{}p", fc.params.len())); }
if fc.calls.len() > 0 { st.push(format!("{} calls", fc.calls.iter().map(|c| c.count).sum::<usize>())); }
if fc.has_loop { st.push("↺".into()); }
let _ = write!(s, r##"<text x="{}" y="{}" fill="#7a7ab0" font-size="10" text-anchor="end">{}</text>"##,
f(x + L_TILE_W - 12.0), f(y + L_TILE_H - 10.0), esc(&st.join(" ")));
s
}