Skip to main content

ling/
visualize.rs

1// src/visualize.rs — AST-driven SVG visualiser for .ling source files.
2//
3// Usage: ling visualize examples/foo.ling > foo.svg
4//
5// Each function becomes a card showing colour-coded geometric icons for every
6// vtex_*/audio_*/control-flow call it makes.  Global constants appear in a
7// sidebar.  The entry-point card is highlighted in gold.
8
9use crate::parser::ast::*;
10use std::collections::HashSet;
11use std::fmt::Write;
12
13// ── Layout constants ─────────────────────────────────────────────────────────
14
15const SVG_W:      f32 = 1640.0;
16const SIDEBAR_W:  f32 = 200.0;
17const CARD_W:     f32 = 370.0;
18const CARD_GAP:   f32 = 14.0;
19const GRID_X:     f32 = SIDEBAR_W + 14.0;
20const COLS:       usize = 3;
21const HEADER_H:   f32 = 74.0;
22const LEGEND_H:   f32 = 40.0;
23const CONTENT_Y:  f32 = HEADER_H + LEGEND_H;
24const ICON_SZ:    f32 = 22.0;   // icon bounding box (icon radius = ICON_SZ/2)
25const ICON_GAP:   f32 = 4.0;
26const ICONS_ROW:  usize = 11;
27const CARD_PAD:   f32 = 12.0;
28const CARD_ROUNDING: f32 = 7.0;
29
30// ── Colour palette ───────────────────────────────────────────────────────────
31
32const BG:        &str = "#0b0b1a";
33const CARD_BG:   &str = "#11112a";
34const CARD_BD:   &str = "#22225a";
35const TEXT:      &str = "#c0c0e0";
36const TEXT_DIM:  &str = "#52528a";
37const GOLD:      &str = "#ffd700";
38const SIDEBAR_BG:&str = "#0d0d22";
39
40// ── Call category ────────────────────────────────────────────────────────────
41
42#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
43#[allow(dead_code)]
44enum Cat {
45    Rings, Spiral, Star, Flower, Lotus, Chakra, Yantra,
46    Hyper, Tess, Rain, Grid, Halftone,
47    Tone, Vol, Listen,
48    Camera, Light, Fill, Present,
49    User, Loop, Const,
50}
51
52impl Cat {
53    fn color(self) -> &'static str {
54        match self {
55            Cat::Rings    => "#00e5ff",
56            Cat::Spiral   => "#00ffb3",
57            Cat::Star     => "#ffd700",
58            Cat::Flower   => "#ff79c6",
59            Cat::Lotus    => "#ff5e8a",
60            Cat::Chakra   => "#bd93f9",
61            Cat::Yantra   => "#ffb86c",
62            Cat::Hyper    => "#5575c8",
63            Cat::Tess     => "#50fa7b",
64            Cat::Rain     => "#f1fa8c",
65            Cat::Grid     => "#8be9fd",
66            Cat::Halftone => "#888899",
67            Cat::Tone     => "#ff1493",
68            Cat::Vol      => "#ff69b4",
69            Cat::Listen   => "#db77d9",
70            Cat::Camera   => "#ffe066",
71            Cat::Light    => "#ffe4b5",
72            Cat::Fill     => "#3a3a6a",
73            Cat::Present  => "#555580",
74            Cat::User     => "#6ab0f5",
75            Cat::Loop     => "#ff7f50",
76            Cat::Const    => "#50fa7b",
77        }
78    }
79
80    fn label(self) -> &'static str {
81        match self {
82            Cat::Rings    => "rings",
83            Cat::Spiral   => "spiral",
84            Cat::Star     => "star",
85            Cat::Flower   => "flower",
86            Cat::Lotus    => "lotus",
87            Cat::Chakra   => "chakra",
88            Cat::Yantra   => "yantra",
89            Cat::Hyper    => "hyperbolic",
90            Cat::Tess     => "tessellated",
91            Cat::Rain     => "letter rain",
92            Cat::Grid     => "grid",
93            Cat::Halftone => "halftone",
94            Cat::Tone     => "audio tone",
95            Cat::Vol      => "audio vol",
96            Cat::Listen   => "listener",
97            Cat::Camera   => "camera",
98            Cat::Light    => "light",
99            Cat::Fill     => "fill",
100            Cat::Present  => "render",
101            Cat::User     => "fn call",
102            Cat::Loop     => "loop",
103            Cat::Const    => "const",
104        }
105    }
106}
107
108fn categorize(name: &str) -> Cat {
109    if name.starts_with("vtex_rings")       || name == "ลายวงซ้อน"         { return Cat::Rings; }
110    if name.starts_with("vtex_spiral")      || name == "ลายก้นหอย"         { return Cat::Spiral; }
111    if name.starts_with("vtex_star")        || name == "ลายดาว"             { return Cat::Star; }
112    if name.starts_with("vtex_flower")      || name == "ลายดอกไม้"          { return Cat::Flower; }
113    if name.starts_with("vtex_lotus")       || name == "ลายบัว"             { return Cat::Lotus; }
114    if name.starts_with("vtex_chakra")      || name == "ลายจักร"            { return Cat::Chakra; }
115    if name.starts_with("vtex_yantra")      || name == "ลายยันต์"           { return Cat::Yantra; }
116    if name.starts_with("vtex_hyperbolic")  || name == "ลายไฮเพอร์โบลิก"   { return Cat::Hyper; }
117    if name.starts_with("vtex_tessellated") || name == "ลายตาข่าย"          { return Cat::Tess; }
118    if name.starts_with("vtex_letter_rain") || name == "ลายอักษรไหล"        { return Cat::Rain; }
119    if name.starts_with("vtex_grid")        || name == "ลายตาราง"           { return Cat::Grid; }
120    if name.starts_with("vtex_halftone")    || name == "ลายจุด"             { return Cat::Halftone; }
121    match name {
122        "audio_tone"                            => Cat::Tone,
123        "audio_volume"                          => Cat::Vol,
124        "audio_listener"                        => Cat::Listen,
125        "set_camera"                            => Cat::Camera,
126        "add_light" | "clear_lights"            => Cat::Light,
127        "เติม"  | "fill"                        => Cat::Fill,
128        "แสดงผล" | "present"                    => Cat::Present,
129        _                                       => Cat::User,
130    }
131}
132
133fn is_vtex(c: Cat) -> bool {
134    matches!(c, Cat::Rings|Cat::Spiral|Cat::Star|Cat::Flower|Cat::Lotus|
135               Cat::Chakra|Cat::Yantra|Cat::Hyper|Cat::Tess|Cat::Rain|
136               Cat::Grid|Cat::Halftone)
137}
138fn is_audio(c: Cat) -> bool { matches!(c, Cat::Tone|Cat::Vol|Cat::Listen) }
139
140const ENTRY_NAMES: &[&str] = &[
141    "start","main","启","เริ่ม","시작","начать","начало",
142    "inicio","comenzar","début","commencer","anfang","starten","início",
143];
144fn is_entry(name: &str) -> bool { ENTRY_NAMES.contains(&name) }
145
146// ── Data model ────────────────────────────────────────────────────────────────
147
148struct GlobalConst { name: String, value: String }
149
150#[derive(Clone)]
151struct CallItem { name: String, cat: Cat, count: usize }
152
153struct FuncCard {
154    name:      String,
155    params:    Vec<String>,
156    calls:     Vec<CallItem>,
157    has_loop:  bool,
158    is_entry:  bool,
159    vtex_count:  usize,
160    audio_count: usize,
161}
162
163struct Document {
164    filename: String,
165    globals:  Vec<GlobalConst>,
166    funcs:    Vec<FuncCard>,
167    #[allow(dead_code)]
168    fn_names: HashSet<String>,
169}
170
171// ── AST walking ───────────────────────────────────────────────────────────────
172
173struct RawCall { name: String, cat: Cat }
174
175fn walk_stmts(stmts: &[Stmt], fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
176    for s in stmts {
177        let e = match s { Stmt::Expr(e)|Stmt::Return(e) => e, Stmt::Bind(_,e) => e };
178        walk_expr(e, fns, out, loop_);
179    }
180}
181
182fn walk_expr(e: &Expr, fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
183    match e {
184        Expr::Call(func, args) => {
185            if let Expr::Ident(name) = func.as_ref() {
186                let cat = if fns.contains(name.as_str()) {
187                    let base = categorize(name);
188                    if base == Cat::User { Cat::User } else { base }
189                } else {
190                    categorize(name)
191                };
192                out.push(RawCall { name: name.clone(), cat });
193            }
194            for a in args { walk_expr(a, fns, out, loop_); }
195        }
196        Expr::While { cond, body } => {
197            *loop_ = true;
198            walk_expr(cond, fns, out, loop_);
199            walk_stmts(body, fns, out, loop_);
200        }
201        Expr::Do(ss) => walk_stmts(ss, fns, out, loop_),
202        Expr::If { cond, then, elseifs, else_body } => {
203            walk_expr(cond, fns, out, loop_);
204            walk_stmts(then, fns, out, loop_);
205            for (c,b) in elseifs { walk_expr(c,fns,out,loop_); walk_stmts(b,fns,out,loop_); }
206            if let Some(b) = else_body { walk_stmts(b, fns, out, loop_); }
207        }
208        Expr::For { iter, body, .. } => {
209            walk_expr(iter, fns, out, loop_);
210            walk_stmts(body, fns, out, loop_);
211        }
212        Expr::BinOp(_, a, b) => { walk_expr(a, fns, out, loop_); walk_expr(b, fns, out, loop_); }
213        Expr::Array(es) => { for a in es { walk_expr(a, fns, out, loop_); } }
214        _ => {}
215    }
216}
217
218fn aggregate(raw: Vec<RawCall>) -> Vec<CallItem> {
219    let mut out: Vec<CallItem> = Vec::new();
220    for r in raw {
221        if let Some(last) = out.last_mut() {
222            if last.name == r.name { last.count += 1; continue; }
223        }
224        out.push(CallItem { name: r.name, cat: r.cat, count: 1 });
225    }
226    out
227}
228
229fn make_card(name: String, params: Vec<String>, raw: Vec<RawCall>,
230             has_loop: bool, is_entry: bool) -> FuncCard {
231    let calls = aggregate(raw);
232    let vtex_count  = calls.iter().filter(|c| is_vtex(c.cat)).map(|c| c.count).sum();
233    let audio_count = calls.iter().filter(|c| is_audio(c.cat)).map(|c| c.count).sum();
234    FuncCard { name, params, calls, has_loop, is_entry, vtex_count, audio_count }
235}
236
237impl Document {
238    fn build(filename: &str, prog: &Program) -> Self {
239        let fn_names: HashSet<String> = prog.items.iter().filter_map(|i| {
240            if let Item::Fn(f) = i { Some(f.name.clone()) } else { None }
241        }).collect();
242
243        let mut globals = Vec::new();
244        let mut entries = Vec::new();
245        let mut funcs   = Vec::new();
246
247        for item in &prog.items {
248            match item {
249                Item::Bind(name, expr) => match expr {
250                    Expr::Number(n) => globals.push(GlobalConst {
251                        name: name.clone(),
252                        value: if n.fract() == 0.0 { format!("{}", *n as i64) }
253                               else { format!("{:.2}", n) },
254                    }),
255                    Expr::Do(body) => {
256                        let mut raw = Vec::new();
257                        let mut lp = false;
258                        // Scan inside the do block for a while loop too
259                        for s in body {
260                            if let Stmt::Expr(Expr::While { body: wb, .. }) = s {
261                                lp = true;
262                                walk_stmts(wb, &fn_names, &mut raw, &mut lp);
263                            }
264                        }
265                        walk_stmts(body, &fn_names, &mut raw, &mut lp);
266                        entries.push(make_card(name.clone(), vec![], raw, lp, is_entry(name)));
267                    }
268                    _ => {}
269                },
270                Item::Fn(f) => {
271                    let mut raw = Vec::new();
272                    let mut lp  = false;
273                    walk_stmts(&f.body, &fn_names, &mut raw, &mut lp);
274                    funcs.push(make_card(f.name.clone(), f.params.clone(), raw, lp, false));
275                }
276                _ => {}
277            }
278        }
279
280        entries.extend(funcs);
281        Document { filename: filename.to_string(), globals, funcs: entries, fn_names }
282    }
283}
284
285// ── SVG icons ─────────────────────────────────────────────────────────────────
286// Each icon is drawn centred at (cx, cy) within a radius of r≈10.
287
288fn xe(s: &str) -> String { s.replace('&',"&amp;").replace('<',"&lt;").replace('>',"&gt;") }
289fn p(v: f32) -> String { format!("{:.2}", v) }
290
291fn icon(cat: Cat, cx: f32, cy: f32, r: f32) -> String {
292    let c = cat.color();
293    match cat {
294        Cat::Rings => {
295            format!(
296                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.9"/>
297                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.65"/>
298                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.4"/>"#,
299                p(cx),p(cy),p(r), p(cx),p(cy),p(r*0.63), p(cx),p(cy),p(r*0.33)
300            )
301        }
302        Cat::Spiral => {
303            // Approximate spiral with two arcs
304            let (x0,y0) = (cx, cy - r*0.1);
305            let (x1,y1) = (cx + r*0.85, cy);
306            let (x2,y2) = (cx, cy + r*0.9);
307            let (x3,y3) = (cx - r*0.85, cy + r*0.1);
308            let (x4,y4) = (cx, cy - r*0.9);
309            format!(
310                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{} S {},{} {},{} S {},{} {},{}"
311                        fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>"#,
312                p(x0),p(y0),
313                p(cx+r),p(y0), p(x1),p(cy-r*0.5), p(x1),p(y1),
314                p(cx+r*0.3),p(y2), p(x2),p(y2),
315                p(x3),p(y3-r*0.4), p(x3),p(y3),
316                p(cx),p(y4), p(x4),p(y4)
317            )
318        }
319        Cat::Star => {
320            // 5-point star
321            let pts: String = (0..5).flat_map(|i| {
322                let ao = (i as f32 * 72.0 - 90.0).to_radians();
323                let ai = ao + 36.0_f32.to_radians();
324                let ri = r * 0.42;
325                vec![
326                    format!("{},{}", p(cx + r*ao.cos()), p(cy + r*ao.sin())),
327                    format!("{},{}", p(cx + ri*ai.cos()), p(cy + ri*ai.sin())),
328                ]
329            }).collect::<Vec<_>>().join(" ");
330            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.85"/>"#)
331        }
332        Cat::Flower => {
333            // 6 petals as ellipses
334            (0..6).map(|i| {
335                let angle = i as f32 * 60.0;
336                format!(
337                    r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
338                           fill="{c}" opacity="0.5"
339                           transform="rotate({angle},{},{})"/>"#,
340                    p(cx), p(cy - r*0.45), p(r*0.28), p(r*0.52),
341                    p(cx), p(cy)
342                )
343            }).collect::<String>()
344        }
345        Cat::Lotus => {
346            // 8 petals, more elongated
347            (0..8).map(|i| {
348                let angle = i as f32 * 45.0;
349                format!(
350                    r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
351                           fill="{c}" opacity="0.45"
352                           transform="rotate({angle},{},{})"/>"#,
353                    p(cx), p(cy - r*0.50), p(r*0.22), p(r*0.55),
354                    p(cx), p(cy)
355                )
356            }).collect::<String>()
357                + &format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
358                    p(cx), p(cy), p(r*0.22))
359        }
360        Cat::Chakra => {
361            // Central circle + 8 spokes
362            let spokes: String = (0..8).map(|i| {
363                let a = (i as f32 * 45.0).to_radians();
364                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.8"/>"#,
365                    p(cx + r*0.28*a.cos()), p(cy + r*0.28*a.sin()),
366                    p(cx + r*0.88*a.cos()), p(cy + r*0.88*a.sin()))
367            }).collect();
368            spokes + &format!(
369                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
370                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
371                p(cx), p(cy), p(r*0.88),
372                p(cx), p(cy), p(r*0.2)
373            )
374        }
375        Cat::Yantra => {
376            // Hexagram (Star of David) from two triangles
377            let h = r * 0.866;
378            let t1 = format!("{},{} {},{} {},{}",
379                p(cx), p(cy - r),
380                p(cx + h), p(cy + r*0.5),
381                p(cx - h), p(cy + r*0.5));
382            let t2 = format!("{},{} {},{} {},{}",
383                p(cx), p(cy + r),
384                p(cx - h), p(cy - r*0.5),
385                p(cx + h), p(cy - r*0.5));
386            format!(
387                r#"<polygon points="{t1}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
388                   <polygon points="{t2}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
389                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25"/>"#,
390                p(cx), p(cy), p(r*0.38)
391            )
392        }
393        Cat::Hyper => {
394            // Radial + concentric = Poincaré disc
395            let rays: String = (0..6).map(|i| {
396                let a = (i as f32 * 30.0).to_radians();
397                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"#,
398                    p(cx - r*a.cos()), p(cy - r*a.sin()),
399                    p(cx + r*a.cos()), p(cy + r*a.sin()))
400            }).collect();
401            rays + &format!(
402                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>
403                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.5"/>
404                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.3"/>"#,
405                p(cx),p(cy),p(r),   p(cx),p(cy),p(r*0.6),   p(cx),p(cy),p(r*0.3)
406            )
407        }
408        Cat::Tess => {
409            // Wavy horizontal lines
410            (0..4).map(|i| {
411                let y = cy - r*0.7 + i as f32 * r*0.46;
412                let amp = r * 0.15;
413                format!(
414                    r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
415                            fill="none" stroke="{c}" stroke-width="1.1" opacity="0.7"/>"#,
416                    p(cx-r), p(y),
417                    p(cx-r+r*0.4), p(y-amp),  p(cx-r*0.1), p(y-amp),  p(cx), p(y),
418                    p(cx+r*0.5), p(y+amp),  p(cx+r), p(y)
419                )
420            }).collect::<String>()
421        }
422        Cat::Rain => {
423            // Falling dashes (letter rain columns)
424            (0..5).flat_map(|col| {
425                let x = cx - r*0.9 + col as f32 * r*0.45;
426                (0..3).map(move |row| {
427                    let y = cy - r*0.8 + row as f32 * r*0.55;
428                    let opa = 0.9 - row as f32 * 0.22;
429                    format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="{:.2}"/>"#,
430                        p(x - r*0.06), p(y), p(r*0.12), p(r*0.30), opa)
431                })
432            }).collect::<String>()
433        }
434        Cat::Grid => {
435            // # hash grid
436            let n = 3;
437            let step = r * 2.0 / n as f32;
438            let mut s = String::new();
439            for i in 0..=n {
440                let off = -r + i as f32 * step;
441                write!(s, r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>
442                             <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>"#,
443                    p(cx+off),p(cy-r), p(cx+off),p(cy+r),
444                    p(cx-r),p(cy+off), p(cx+r),p(cy+off)).ok();
445            }
446            s
447        }
448        Cat::Halftone => {
449            // 4×4 dot grid
450            (0..4).flat_map(|row| (0..4).map(move |col| {
451                let x = cx - r*0.75 + col as f32 * r*0.5;
452                let y = cy - r*0.75 + row as f32 * r*0.5;
453                format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.6"/>"#,
454                    p(x), p(y), p(r*0.14))
455            })).collect::<String>()
456        }
457        Cat::Tone => {
458            // Sine wave
459            let x0 = cx - r; let x3 = cx + r; let xm = cx;
460            let amp = r * 0.65;
461            format!(
462                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
463                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>"#,
464                p(x0), p(cy),
465                p(x0 + r*0.5), p(cy - amp), p(xm - r*0.1), p(cy - amp), p(xm), p(cy),
466                p(xm + r*0.6), p(cy + amp), p(x3), p(cy)
467            )
468        }
469        Cat::Vol | Cat::Listen => {
470            // Concentric arcs (speaker/ear)
471            let arcs: String = (1..=3).map(|i| {
472                let ri = r * 0.3 * i as f32;
473                format!(
474                    r#"<path d="M {},{} A {ri},{ri} 0 0,1 {},{}"
475                            fill="none" stroke="{c}" stroke-width="1.3" opacity="{:.2}"/>"#,
476                    p(cx), p(cy - ri),
477                    p(cx), p(cy + ri),
478                    1.0 - i as f32 * 0.22
479                )
480            }).collect();
481            arcs + &format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.7"/>"#,
482                p(cx - r*0.15), p(cy), p(r*0.22))
483        }
484        Cat::Camera => {
485            // Lens: outer circle + inner circle + crosshair dot
486            format!(
487                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.8"/>
488                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.55"/>
489                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"#,
490                p(cx), p(cy), p(r),
491                p(cx), p(cy), p(r*0.6),
492                p(cx), p(cy), p(r*0.18)
493            )
494        }
495        Cat::Light => {
496            // Sunburst: central circle + 8 rays
497            let rays: String = (0..8).map(|i| {
498                let a = (i as f32 * 45.0).to_radians();
499                let r1 = r * 0.38;
500                let r2 = r * 0.90;
501                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.75"/>"#,
502                    p(cx + r1*a.cos()), p(cy + r1*a.sin()),
503                    p(cx + r2*a.cos()), p(cy + r2*a.sin()))
504            }).collect();
505            rays + &format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"#,
506                p(cx), p(cy), p(r*0.32))
507        }
508        Cat::Fill => {
509            format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"#,
510                p(cx - r*0.9), p(cy - r*0.7), p(r*1.8), p(r*1.4))
511        }
512        Cat::Present => {
513            // Triangle (play/render button)
514            let pts = format!("{},{} {},{} {},{}",
515                p(cx - r*0.7), p(cy - r*0.8),
516                p(cx + r*0.8), p(cy),
517                p(cx - r*0.7), p(cy + r*0.8));
518            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.7"/>"#)
519        }
520        Cat::User => {
521            // Right-pointing chevron (function call arrow)
522            format!(
523                r#"<path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
524                         stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
525                   <path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
526                         stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>"#,
527                p(cx-r*0.6), p(cy-r*0.7), p(cx+r*0.5), p(cy), p(cx-r*0.6), p(cy+r*0.7),
528                p(cx), p(cy-r*0.7), p(cx+r*0.95), p(cy), p(cx), p(cy+r*0.7)
529            )
530        }
531        Cat::Loop => {
532            // Circular arrow
533            format!(
534                r#"<path d="M {},{} A {},{} 0 1,1 {},{}"
535                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
536                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"#,
537                p(cx), p(cy - r*0.9),
538                p(r*0.9), p(r*0.9),
539                p(cx + r*0.4), p(cy - r*0.9),
540                p(cx+r*0.4), p(cy-r*0.9),
541                p(cx+r*0.1), p(cy-r*0.55),
542                p(cx+r*0.8), p(cy-r*0.62)
543            )
544        }
545        Cat::Const => {
546            format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2"
547                           fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
548                       <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.1" opacity="0.5"/>"#,
549                p(cx-r*0.7), p(cy-r*0.55), p(r*1.4), p(r*1.1),
550                p(cx-r*0.4), p(cy), p(cx+r*0.4), p(cy))
551        }
552    }
553}
554
555// ── Card rendering ────────────────────────────────────────────────────────────
556
557fn card_height(card: &FuncCard) -> f32 {
558    let icon_rows = (card.calls.len() + ICONS_ROW - 1).max(1) / ICONS_ROW + 1;
559    let base = CARD_PAD * 2.0          // top+bottom padding
560        + 32.0                          // name header
561        + 20.0;                         // params + stats row
562    base + icon_rows as f32 * (ICON_SZ + ICON_GAP) + ICON_GAP
563}
564
565fn render_card(card: &FuncCard, x: f32, y: f32) -> String {
566    let h = card_height(card);
567    let w = CARD_W;
568    let r = CARD_ROUNDING;
569
570    // Dominant colour from most-common call category (by raw count)
571    let dominant = card.calls.iter()
572        .max_by_key(|c| c.count)
573        .map(|c| c.cat.color())
574        .unwrap_or(Cat::User.color());
575
576    let border_color = if card.is_entry { GOLD } else { dominant };
577    let border_w     = if card.is_entry { 2.5  } else { 1.2 };
578    let glow_filter  = if card.is_entry { r#" filter="url(#glow-gold)""# } else { "" };
579
580    let mut s = String::new();
581
582    // Card background
583    write!(s, r#"<rect x="{}" y="{}" width="{w}" height="{h}" rx="{r}"
584                      fill="{CARD_BG}" stroke="{border_color}" stroke-width="{border_w}"{glow_filter}/>
585                 <line x1="{}" y1="{}" x2="{}" y2="{}"
586                      stroke="{border_color}" stroke-width="3" opacity="0.6"/>
587"#,
588        p(x), p(y),
589        p(x+r), p(y+h), p(x+r), p(y)   // left accent stripe
590    ).ok();
591
592    // Function name
593    let name_y = y + CARD_PAD + 18.0;
594    let entry_badge = if card.is_entry {
595        format!(r#" <text x="{}" y="{}" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.8">⬡ ENTRY</text>"#,
596            p(x + w - CARD_PAD - 48.0), p(name_y))
597    } else { String::new() };
598
599    write!(s, r#"<text x="{}" y="{}" fill="{}" font-size="13" font-weight="bold">{}</text>{}"#,
600        p(x + CARD_PAD + 10.0), p(name_y),
601        if card.is_entry { GOLD } else { TEXT },
602        xe(&card.name),
603        entry_badge
604    ).ok();
605
606    // Params + stats row
607    let stats_y = name_y + 18.0;
608    let params_str = if card.params.is_empty() { String::new() }
609        else { format!("({})", card.params.join(", ")) };
610    let stats_str = {
611        let mut parts = Vec::new();
612        if card.vtex_count > 0  { parts.push(format!("{} vtex",  card.vtex_count)); }
613        if card.audio_count > 0 { parts.push(format!("{} audio", card.audio_count)); }
614        if card.has_loop        { parts.push("↺ loop".into()); }
615        parts.join("  ·  ")
616    };
617    write!(s, r#"<text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{}</text>
618                 <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10" text-anchor="end">{}</text>
619"#,
620        p(x + CARD_PAD + 10.0), p(stats_y), xe(&params_str),
621        p(x + w - CARD_PAD), p(stats_y), xe(&stats_str)
622    ).ok();
623
624    // Divider
625    let div_y = stats_y + 6.0;
626    write!(s, r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{CARD_BD}" stroke-width="1"/>"#,
627        p(x + CARD_PAD), p(div_y), p(x + w - CARD_PAD), p(div_y)).ok();
628
629    // Icons
630    let icon_y0 = div_y + ICON_GAP + ICON_SZ / 2.0;
631    let icon_x0 = x + CARD_PAD + ICON_SZ / 2.0 + 6.0;
632    let ir = ICON_SZ / 2.0 * 0.82; // icon radius slightly smaller than half-cell
633
634    for (i, call) in card.calls.iter().enumerate() {
635        let row = i / ICONS_ROW;
636        let col = i % ICONS_ROW;
637        let ix = icon_x0 + col as f32 * (ICON_SZ + ICON_GAP);
638        let iy = icon_y0 + row as f32 * (ICON_SZ + ICON_GAP);
639
640        // Icon cell background
641        write!(s, r#"<rect x="{}" y="{}" width="{ICON_SZ}" height="{ICON_SZ}" rx="3"
642                          fill="{}" opacity="0.12"/>
643"#,
644            p(ix - ICON_SZ/2.0), p(iy - ICON_SZ/2.0),
645            call.cat.color()
646        ).ok();
647
648        // Icon shape
649        s.push_str(&icon(call.cat, ix, iy, ir));
650
651        // Count badge (if > 1)
652        if call.count > 1 {
653            write!(s, r##"<rect x="{}" y="{}" width="13" height="10" rx="3" fill="{}" opacity="0.9"/>
654                         <text x="{}" y="{}" fill="#0a0a1a" font-size="8" font-weight="bold" text-anchor="middle">{}</text>"##,
655                p(ix + ir - 2.0), p(iy - ir - 1.0), call.cat.color(),
656                p(ix + ir + 4.5), p(iy - ir + 7.0), call.count
657            ).ok();
658        }
659    }
660
661    s
662}
663
664// ── Legend, header, sidebar ───────────────────────────────────────────────────
665
666fn render_header(filename: &str, funcs: &[FuncCard]) -> String {
667    let name = std::path::Path::new(filename)
668        .file_name().map(|n| n.to_string_lossy().into_owned())
669        .unwrap_or_else(|| filename.to_string());
670    let n_fns   = funcs.len();
671    let n_vtex: usize  = funcs.iter().flat_map(|f| &f.calls).filter(|c| is_vtex(c.cat)).map(|c| c.count).sum();
672    let n_audio: usize = funcs.iter().flat_map(|f| &f.calls).filter(|c| is_audio(c.cat)).map(|c| c.count).sum();
673    let n_calls: usize = funcs.iter().map(|f| f.calls.len()).sum();
674
675    format!(
676        r##"<rect x="0" y="0" width="{SVG_W}" height="{HEADER_H}" fill="#080814"/>
677           <line x1="0" y1="{HEADER_H}" x2="{SVG_W}" y2="{HEADER_H}" stroke="#1a1a3a" stroke-width="1"/>
678           <text x="20" y="22" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.6" letter-spacing="2">LING VISUALIZER</text>
679           <text x="20" y="56" fill="{TEXT}" font-size="26" font-weight="bold">{}</text>
680           <text x="{}" y="56" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{n_fns} fn  ·  {n_calls} call types  ·  {n_vtex} vtex  ·  {n_audio} audio</text>"##,
681        xe(&name),
682        SVG_W - 20.0
683    )
684}
685
686fn render_legend(funcs: &[FuncCard]) -> String {
687    // Collect present categories from all cards
688    let mut present: Vec<Cat> = Vec::new();
689    let mut seen = HashSet::new();
690    for f in funcs {
691        for c in &f.calls {
692            if seen.insert(c.cat) { present.push(c.cat); }
693        }
694    }
695
696    let ly = HEADER_H + LEGEND_H / 2.0 + 4.0;
697    let mut s = format!(
698        r##"<rect x="0" y="{HEADER_H}" width="{SVG_W}" height="{LEGEND_H}" fill="#0a0a18"/>
699           <line x1="0" y1="{}" x2="{SVG_W}" y2="{}" stroke="#171730" stroke-width="1"/>"##,
700        HEADER_H + LEGEND_H, HEADER_H + LEGEND_H
701    );
702
703    let mut lx = GRID_X + 8.0;
704    for cat in present {
705        let c   = cat.color();
706        let lbl = cat.label();
707        let icon_svg = icon(cat, lx + 7.0, ly - 2.0, 6.0);
708        s.push_str(&format!(
709            r#"<rect x="{}" y="{}" width="14" height="14" rx="3" fill="{c}" opacity="0.12"/>
710               {}
711               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{lbl}</text>"#,
712            p(lx), p(ly - 9.0),
713            icon_svg,
714            p(lx + 18.0), p(ly + 3.0)
715        ));
716        lx += 20.0 + lbl.len() as f32 * 6.2 + 10.0;
717        if lx > SVG_W - 120.0 { break; }
718    }
719    s
720}
721
722fn render_sidebar(globals: &[GlobalConst], total_h: f32) -> String {
723    let h = total_h - CONTENT_Y;
724    let mut s = format!(
725        r##"<rect x="0" y="{CONTENT_Y}" width="{SIDEBAR_W}" height="{h}" fill="{SIDEBAR_BG}"/>
726           <line x1="{SIDEBAR_W}" y1="{CONTENT_Y}" x2="{SIDEBAR_W}" y2="{total_h}" stroke="#1a1a3a" stroke-width="1"/>
727           <text x="14" y="{}" fill="{TEXT_DIM}" font-size="9" font-weight="bold" letter-spacing="2">CONSTANTS</text>"##,
728        CONTENT_Y + 20.0
729    );
730
731    let mut gy = CONTENT_Y + 38.0;
732    for g in globals {
733        // Small colored diamond
734        write!(s,
735            r#"<rect x="{}" y="{}" width="8" height="8" rx="1" fill="{}" opacity="0.7"
736                    transform="rotate(45,{},{})"/>
737               <text x="{}" y="{}" fill="{}" font-size="12" font-weight="bold">{}</text>
738               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"#,
739            p(14.0), p(gy - 7.0), Cat::Star.color(),
740            p(18.0), p(gy - 3.0),
741            p(28.0), p(gy), Cat::Grid.color(), xe(&g.name),
742            p(SIDEBAR_W - 10.0), p(gy), xe(&g.value)
743        ).ok();
744        gy += 22.0;
745    }
746    s
747}
748
749// ── SVG defs (filters, patterns) ─────────────────────────────────────────────
750
751const DEFS: &str = r##"<defs>
752  <filter id="glow-gold" x="-30%" y="-30%" width="160%" height="160%">
753    <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
754    <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
755  </filter>
756  <pattern id="grid-pat" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
757    <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#ffffff" stroke-width="0.4"/>
758  </pattern>
759</defs>"##;
760
761// ── Public entry point ────────────────────────────────────────────────────────
762
763pub fn render(filename: &str, program: &Program) -> String {
764    let doc = Document::build(filename, program);
765
766    // Layout: pack cards into COLS columns using shortest-column strategy
767    let heights: Vec<f32> = doc.funcs.iter().map(|f| card_height(f)).collect();
768    let mut col_y = vec![CONTENT_Y; COLS];
769    let mut positions: Vec<(f32, f32)> = Vec::with_capacity(doc.funcs.len());
770
771    for &h in &heights {
772        let (col, &cy) = col_y.iter().enumerate()
773            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap();
774        let cx = GRID_X + col as f32 * (CARD_W + CARD_GAP);
775        positions.push((cx, cy));
776        col_y[col] += h + CARD_GAP;
777    }
778
779    let total_h = col_y.iter().cloned().fold(0.0f32, f32::max) + 40.0;
780
781    let mut svg = String::new();
782    write!(svg,
783        r#"<?xml version="1.0" encoding="UTF-8"?>
784<svg xmlns="http://www.w3.org/2000/svg" width="{SVG_W}" height="{}"
785     style="font-family:'JetBrains Mono','Fira Code',monospace,sans-serif;background:{BG}">"#,
786        total_h
787    ).ok();
788
789    svg.push_str(DEFS);
790
791    // Background
792    write!(svg, r#"<rect width="{SVG_W}" height="{total_h}" fill="{BG}"/>
793                   <rect width="{SVG_W}" height="{total_h}" fill="url(#grid-pat)" opacity="0.05"/>"#,
794        total_h = total_h).ok();
795
796    svg.push_str(&render_header(&doc.filename, &doc.funcs));
797    svg.push_str(&render_legend(&doc.funcs));
798    svg.push_str(&render_sidebar(&doc.globals, total_h));
799
800    for (card, &(cx, cy)) in doc.funcs.iter().zip(positions.iter()) {
801        svg.push_str(&render_card(card, cx, cy));
802    }
803
804    svg.push_str("\n</svg>");
805    svg
806}