Skip to main content

ling/
astviz.rs

1// src/astviz.rs — project-wide AST → SVG in three styles.
2//
3// A whole Ling project is treated as one program (all `.ling` files merged) while
4// preserving each definition's *file scope*. Three renderings are produced:
5//
6//   • Technical — colour-coded function cards with geometric call icons, grouped
7//     into per-file bands, with caller→callee graph edges.
8//   • Artwork   — text-free, esoteric composition of translucent stars/polygons
9//     (3–8 sides) clustered per file; a cubist sigil of the program's shape.
10//   • Ling      — Ling-centric tiles: named, colour-coded, laid out per file with
11//     balance and harmony in mind.
12//
13// Every SVG auto-fits its content (tight viewBox) and declares a 300-DPI physical
14// size so raster exports are crisp. Output is written to ./AST/ by `ling ast`.
15
16use crate::parser::ast::*;
17use crate::visualize::{categorize, icon, is_ai, is_audio, is_crypto, is_physics, is_vtex, Cat};
18use std::collections::HashSet;
19use std::fmt::Write;
20
21// ── Public API ──────────────────────────────────────────────────────────────
22
23#[derive(Clone, Copy, PartialEq, Eq)]
24pub enum AstStyle {
25    Technical,
26    Artwork,
27    Ling,
28}
29
30impl AstStyle {
31    pub fn slug(self) -> &'static str {
32        match self {
33            AstStyle::Technical => "technical",
34            AstStyle::Artwork => "artwork",
35            AstStyle::Ling => "ling",
36        }
37    }
38}
39
40/// Render the merged project (a list of `(file_label, Program)`) in `style`.
41pub fn render(style: AstStyle, project: &str, files: &[(String, Program)]) -> String {
42    let model = Project::analyze(project, files);
43    match style {
44        AstStyle::Technical => render_technical(&model),
45        AstStyle::Artwork => render_artwork(&model),
46        AstStyle::Ling => render_ling(&model),
47    }
48}
49
50// ── Analysed model (file scope preserved) ────────────────────────────────────
51
52struct Call {
53    name: String,
54    cat: Cat,
55    count: usize,
56}
57
58struct Func {
59    name: String,
60    params: Vec<String>,
61    calls: Vec<Call>,
62    has_loop: bool,
63    is_entry: bool,
64    vtex: usize,
65    audio: usize,
66    crypto: usize,
67    physics: usize,
68    ai: usize,
69}
70
71struct FileScope {
72    label: String,
73    funcs: Vec<Func>,
74    globals: Vec<(String, String)>,
75    uses: Vec<String>,
76}
77
78struct Project {
79    name: String,
80    files: Vec<FileScope>,
81    fn_names: HashSet<String>,
82}
83
84const ENTRY_NAMES: &[&str] = &[
85    "start",
86    "main",
87    "启",
88    "เริ่ม",
89    "시작",
90    "始め",
91    "始",
92    "начать",
93    "начало",
94    "inicio",
95    "comenzar",
96    "début",
97    "commencer",
98    "anfang",
99    "starten",
100    "início",
101];
102fn is_entry(name: &str) -> bool {
103    ENTRY_NAMES.contains(&name)
104}
105
106impl Project {
107    fn analyze(name: &str, files: &[(String, Program)]) -> Self {
108        // First pass: every function/entry name across the whole project (for edges).
109        let mut fn_names = HashSet::new();
110        for (_, prog) in files {
111            collect_names(&prog.items, &mut fn_names);
112        }
113
114        let mut scopes = Vec::new();
115        for (label, prog) in files {
116            let mut funcs = Vec::new();
117            let mut globals = Vec::new();
118            let mut uses = Vec::new();
119            collect_scope(&prog.items, "", &mut funcs, &mut globals, &mut uses);
120            // Skip empty files so the canvas stays meaningful.
121            if funcs.is_empty() && globals.is_empty() && uses.is_empty() {
122                continue;
123            }
124            scopes.push(FileScope { label: short_label(label), funcs, globals, uses });
125        }
126        Project { name: name.to_string(), files: scopes, fn_names }
127    }
128
129    fn total_funcs(&self) -> usize {
130        self.files.iter().map(|f| f.funcs.len()).sum()
131    }
132
133    /// `files · fns` plus any present domain tallies (vtex/audio/crypto/physics/ai).
134    fn subtitle(&self) -> String {
135        let mut parts = vec![
136            format!("{} files", self.files.len()),
137            format!("{} fns", self.total_funcs()),
138        ];
139        let (mut v, mut a, mut c, mut ph, mut ai) = (0, 0, 0, 0, 0);
140        for fs in &self.files {
141            for fc in &fs.funcs {
142                v += fc.vtex;
143                a += fc.audio;
144                c += fc.crypto;
145                ph += fc.physics;
146                ai += fc.ai;
147            }
148        }
149        for (n, lbl) in [
150            (v, "vtex"),
151            (a, "audio"),
152            (c, "crypto"),
153            (ph, "physics"),
154            (ai, "ai"),
155        ] {
156            if n > 0 {
157                parts.push(format!("{n} {lbl}"));
158            }
159        }
160        parts.join(" · ")
161    }
162}
163
164fn short_label(path: &str) -> String {
165    let p = path.replace('\\', "/");
166    p.rsplit('/').next().unwrap_or(&p).to_string()
167}
168
169fn collect_names(items: &[Item], out: &mut HashSet<String>) {
170    for it in items {
171        match it {
172            Item::Fn(f) => {
173                out.insert(f.name.clone());
174            },
175            Item::Bind(name, Expr::Do(_)) => {
176                out.insert(name.clone());
177            },
178            Item::Mod(_, body) => collect_names(body, out),
179            _ => {},
180        }
181    }
182}
183
184fn collect_scope(
185    items: &[Item],
186    ns: &str,
187    funcs: &mut Vec<Func>,
188    globals: &mut Vec<(String, String)>,
189    uses: &mut Vec<String>,
190) {
191    let q = |n: &str| {
192        if ns.is_empty() {
193            n.to_string()
194        } else {
195            format!("{ns}::{n}")
196        }
197    };
198    for it in items {
199        match it {
200            Item::Fn(f) => funcs.push(build_func(
201                q(&f.name),
202                f.params.clone(),
203                &f.body,
204                is_entry(&f.name),
205            )),
206            Item::Bind(name, Expr::Do(body)) => {
207                funcs.push(build_func(q(name), vec![], body, is_entry(name)));
208            },
209            Item::Bind(name, expr) => globals.push((q(name), value_repr(expr))),
210            Item::Mod(name, body) => collect_scope(body, &q(name), funcs, globals, uses),
211            Item::Use { path, alias } => {
212                let a = alias
213                    .as_deref()
214                    .map(|x| format!(" as {x}"))
215                    .unwrap_or_default();
216                uses.push(format!("{path}{a}"));
217            },
218            Item::TypeAlias(n, t) => globals.push((q(n), format!("type {t}"))),
219            Item::Struct(n, fields) => {
220                globals.push((q(n), format!("form {{{}}}", fields.join(", "))))
221            },
222            Item::Enum(n, variants) => {
223                let names: Vec<&str> = variants.iter().map(|v| v.name.as_str()).collect();
224                globals.push((q(n), format!("choose {{{}}}", names.join(" | "))));
225            },
226        }
227    }
228}
229
230fn value_repr(e: &Expr) -> String {
231    match e {
232        Expr::Number(n) => {
233            if n.fract() == 0.0 {
234                format!("{}", *n as i64)
235            } else {
236                format!("{n:.2}")
237            }
238        },
239        Expr::Str(_) => "\"…\"".into(),
240        Expr::Bool(b) => format!("{b}"),
241        Expr::Array(_) => "[…]".into(),
242        _ => "…".into(),
243    }
244}
245
246fn build_func(name: String, params: Vec<String>, body: &[Stmt], is_entry: bool) -> Func {
247    let mut raw = Vec::new();
248    let mut has_loop = false;
249    walk_stmts(body, &mut raw, &mut has_loop);
250    let calls = aggregate(raw);
251    let sum = |pred: fn(Cat) -> bool| calls.iter().filter(|c| pred(c.cat)).map(|c| c.count).sum();
252    let vtex = sum(is_vtex);
253    let audio = sum(is_audio);
254    let crypto = sum(is_crypto);
255    let physics = sum(is_physics);
256    let ai = sum(is_ai);
257    Func {
258        name,
259        params,
260        calls,
261        has_loop,
262        is_entry,
263        vtex,
264        audio,
265        crypto,
266        physics,
267        ai,
268    }
269}
270
271fn aggregate(raw: Vec<(String, Cat)>) -> Vec<Call> {
272    let mut out: Vec<Call> = Vec::new();
273    for (name, cat) in raw {
274        if let Some(l) = out.last_mut() {
275            if l.name == name {
276                l.count += 1;
277                continue;
278            }
279        }
280        out.push(Call { name, cat, count: 1 });
281    }
282    out
283}
284
285// ── AST walking (thorough: catches calls in every expression position) ────────
286
287fn walk_stmts(stmts: &[Stmt], out: &mut Vec<(String, Cat)>, lp: &mut bool) {
288    for s in stmts {
289        match s {
290            Stmt::Expr(e) | Stmt::Return(e) | Stmt::Bind(_, e) => walk_expr(e, out, lp),
291        }
292    }
293}
294
295fn walk_expr(e: &Expr, out: &mut Vec<(String, Cat)>, lp: &mut bool) {
296    match e {
297        Expr::Call(func, args) => {
298            let name = match func.as_ref() {
299                Expr::Ident(n) => Some(n.clone()),
300                Expr::Path(segs) => segs.last().cloned(),
301                _ => {
302                    walk_expr(func, out, lp);
303                    None
304                },
305            };
306            if let Some(n) = name {
307                let cat = categorize(&n);
308                out.push((n, cat));
309            }
310            for a in args {
311                walk_expr(a, out, lp);
312            }
313        },
314        Expr::MethodCall { receiver, method, args } => {
315            walk_expr(receiver, out, lp);
316            out.push((method.clone(), categorize(method)));
317            for a in args {
318                walk_expr(a, out, lp);
319            }
320        },
321        Expr::While { cond, body } => {
322            *lp = true;
323            walk_expr(cond, out, lp);
324            walk_stmts(body, out, lp);
325        },
326        Expr::For { iter, body, .. } => {
327            *lp = true;
328            walk_expr(iter, out, lp);
329            walk_stmts(body, out, lp);
330        },
331        Expr::Do(ss) => walk_stmts(ss, out, lp),
332        Expr::If { cond, then, elseifs, else_body } => {
333            walk_expr(cond, out, lp);
334            walk_stmts(then, out, lp);
335            for (c, b) in elseifs {
336                walk_expr(c, out, lp);
337                walk_stmts(b, out, lp);
338            }
339            if let Some(b) = else_body {
340                walk_stmts(b, out, lp);
341            }
342        },
343        Expr::Match(scrut, arms) => {
344            walk_expr(scrut, out, lp);
345            for a in arms {
346                walk_expr(&a.body, out, lp);
347            }
348        },
349        Expr::BinOp(_, a, b) | Expr::Range(a, b) | Expr::Index(a, b) => {
350            walk_expr(a, out, lp);
351            walk_expr(b, out, lp);
352        },
353        Expr::Ref(x) | Expr::Await(x) => walk_expr(x, out, lp),
354        Expr::Closure(_, body) => walk_expr(body, out, lp),
355        Expr::Array(es) => {
356            for a in es {
357                walk_expr(a, out, lp);
358            }
359        },
360        _ => {},
361    }
362}
363
364// ── Shared SVG helpers ────────────────────────────────────────────────────────
365
366fn esc(s: &str) -> String {
367    s.replace('&', "&amp;")
368        .replace('<', "&lt;")
369        .replace('>', "&gt;")
370}
371fn f(v: f32) -> String {
372    format!("{v:.2}")
373}
374
375/// Wrap a body in an auto-fit, 300-DPI SVG document. `w`/`h` are the content
376/// extents in user units (px); physical size is `w/300 × h/300` inches.
377fn svg_doc(w: f32, h: f32, bg_body: &str, body: &str) -> String {
378    format!(
379        r##"<?xml version="1.0" encoding="UTF-8"?>
380<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">
381{bg_body}{body}
382</svg>"##,
383        win = w / 300.0,
384        hin = h / 300.0,
385        w = f(w),
386        h = f(h),
387    )
388}
389
390/// Star polygon points: `sides` points alternating outer/inner radius.
391fn star_points(cx: f32, cy: f32, ro: f32, ri: f32, sides: usize, rot: f32) -> String {
392    let n = sides.max(2);
393    (0..n * 2)
394        .map(|i| {
395            let a = rot + i as f32 * std::f32::consts::PI / n as f32;
396            let r = if i % 2 == 0 { ro } else { ri };
397            format!("{},{}", f(cx + r * a.cos()), f(cy + r * a.sin()))
398        })
399        .collect::<Vec<_>>()
400        .join(" ")
401}
402
403/// Regular polygon points.
404fn ngon_points(cx: f32, cy: f32, r: f32, sides: usize, rot: f32) -> String {
405    let n = sides.max(3);
406    (0..n)
407        .map(|i| {
408            let a = rot - std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::TAU / n as f32;
409            format!("{},{}", f(cx + r * a.cos()), f(cy + r * a.sin()))
410        })
411        .collect::<Vec<_>>()
412        .join(" ")
413}
414
415/// Tiny deterministic hash → used for reproducible "random" placement.
416fn hash(s: &str) -> u64 {
417    let mut h = 1469598103934665603u64;
418    for b in s.bytes() {
419        h ^= b as u64;
420        h = h.wrapping_mul(1099511628211);
421    }
422    h
423}
424fn frand(seed: u64, i: u64) -> f32 {
425    let mut x = seed ^ i.wrapping_mul(0x9E3779B97F4A7C15);
426    x ^= x >> 30;
427    x = x.wrapping_mul(0xBF58476D1CE4E5B9);
428    x ^= x >> 27;
429    x = x.wrapping_mul(0x94D049BB133111EB);
430    x ^= x >> 31;
431    (x as f64 / u64::MAX as f64) as f32
432}
433
434/// Sides (3..=8) chosen deterministically from a call category + name.
435fn shape_sides(cat: Cat, name: &str) -> usize {
436    let base = match cat {
437        Cat::Star | Cat::Yantra | Cat::Sign | Cat::Shard => 5,
438        Cat::Flower | Cat::Chakra | Cat::Cog | Cat::Music | Cat::Hash | Cat::Trig => 6,
439        Cat::Lotus | Cat::Holo | Cat::Spectrum => 8,
440        Cat::Present | Cat::Force | Cat::Fractal | Cat::Torii | Cat::Draw3D | Cat::Pagoda => 3,
441        Cat::Fill
442        | Cat::Grid
443        | Cat::Window
444        | Cat::Widget
445        | Cat::Rigid
446        | Cat::Key
447        | Cat::Cipher
448        | Cat::File => 4,
449        Cat::Rain | Cat::Net | Cat::Neural | Cat::Sfx => 7,
450        _ => 0,
451    };
452    if base != 0 {
453        base
454    } else {
455        3 + (hash(name) % 6) as usize
456    }
457}
458
459// ── Background ────────────────────────────────────────────────────────────────
460
461const BG: &str = "#0b0b1a";
462
463fn bg_rect(w: f32, h: f32, fill: &str) -> String {
464    format!(
465        r##"<rect x="0" y="0" width="{}" height="{}" fill="{}"/>"##,
466        f(w),
467        f(h),
468        fill
469    )
470}
471
472// ══════════════════════════════════════════════════════════════════════════════
473// 1. TECHNICAL — file-banded function cards + call icons + graph edges
474// ══════════════════════════════════════════════════════════════════════════════
475
476const T_MARGIN: f32 = 36.0;
477const T_TITLE_H: f32 = 96.0;
478const T_CARD_W: f32 = 360.0;
479const T_CARD_GAP: f32 = 16.0;
480const T_COLS: usize = 3;
481const T_FILE_HDR: f32 = 40.0;
482const T_ICON: f32 = 24.0;
483const T_ICONS_ROW: usize = 10;
484
485fn t_card_h(fc: &Func) -> f32 {
486    let rows = (fc.calls.len() + T_ICONS_ROW - 1).max(1) / T_ICONS_ROW + 1;
487    24.0 * 2.0 + 30.0 + 18.0 + rows as f32 * (T_ICON + 5.0) + 6.0
488}
489
490fn render_technical(p: &Project) -> String {
491    let content_w = T_COLS as f32 * (T_CARD_W + T_CARD_GAP) - T_CARD_GAP;
492    let w = content_w + T_MARGIN * 2.0;
493
494    // Layout pass: per file, pack cards into T_COLS shortest-column order.
495    // Records (function-name → card-centre) for edges.
496    struct Placed<'a> {
497        fc: &'a Func,
498        x: f32,
499        y: f32,
500        h: f32,
501    }
502    let mut placed: Vec<Placed> = Vec::new();
503    let mut centers: std::collections::HashMap<String, (f32, f32)> =
504        std::collections::HashMap::new();
505    let mut file_bands: Vec<(String, usize, f32, f32)> = Vec::new(); // (label, n_glob, y, height)
506
507    let mut y = T_TITLE_H + 10.0;
508    for fs in &p.files {
509        let band_top = y;
510        y += T_FILE_HDR;
511        let mut col_y = [y; T_COLS];
512        for fc in &fs.funcs {
513            let h = t_card_h(fc);
514            let (col, cy) = col_y
515                .iter()
516                .enumerate()
517                .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
518                .map(|(i, &v)| (i, v))
519                .unwrap();
520            let x = T_MARGIN + col as f32 * (T_CARD_W + T_CARD_GAP);
521            centers.insert(fc.name.clone(), (x + T_CARD_W / 2.0, cy + h / 2.0));
522            placed.push(Placed { fc, x, y: cy, h });
523            col_y[col] = cy + h + T_CARD_GAP;
524        }
525        let band_bottom = col_y.iter().cloned().fold(y, f32::max);
526        // Reserve room for globals/uses summary under the band.
527        let extra = if fs.globals.is_empty() && fs.uses.is_empty() {
528            0.0
529        } else {
530            26.0
531        };
532        file_bands.push((
533            fs.label.clone(),
534            fs.globals.len() + fs.uses.len(),
535            band_top,
536            band_bottom - band_top + extra,
537        ));
538        y = band_bottom + extra + 24.0;
539    }
540    let h = y + T_MARGIN;
541
542    let mut body = String::new();
543    body.push_str(DEFS_T);
544
545    // Subtle grid overlay
546    let _ = write!(
547        body,
548        r##"<rect width="{}" height="{}" fill="url(#tgrid)" opacity="0.06"/>"##,
549        f(w),
550        f(h)
551    );
552
553    // Title
554    let _ = write!(
555        body,
556        r##"<text x="{}" y="44" fill="#ffd700" font-size="11" font-weight="bold" letter-spacing="3" opacity="0.7">LING · AST · TECHNICAL</text>
557            <text x="{}" y="78" fill="#d8d8ff" font-size="30" font-weight="bold">{}</text>
558            <text x="{}" y="78" fill="#52528a" font-size="13" text-anchor="end">{}</text>"##,
559        f(T_MARGIN),
560        f(T_MARGIN),
561        esc(&p.name),
562        f(w - T_MARGIN),
563        esc(&p.subtitle())
564    );
565
566    // Graph edges (behind cards): caller centre → callee centre, faint curves.
567    for pl in &placed {
568        let (x0, y0) = *centers.get(&pl.fc.name).unwrap();
569        for c in &pl.fc.calls {
570            if !p.fn_names.contains(&c.name) {
571                continue;
572            }
573            if let Some(&(x1, y1)) = centers.get(&c.name) {
574                if (x0 - x1).abs() < 0.5 && (y0 - y1).abs() < 0.5 {
575                    continue;
576                }
577                let mx = (x0 + x1) / 2.0;
578                let _ = write!(
579                    body,
580                    r##"<path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{}" stroke-width="1.1" opacity="0.22"/>"##,
581                    f(x0),
582                    f(y0),
583                    f(mx),
584                    f(y0),
585                    f(mx),
586                    f(y1),
587                    f(x1),
588                    f(y1),
589                    c.cat.color()
590                );
591            }
592        }
593    }
594
595    // File band frames + headers
596    for (label, _n, by, bh) in &file_bands {
597        let _ = write!(
598            body,
599            r##"<rect x="{}" y="{}" width="{}" height="{}" rx="12" fill="#10102a" opacity="0.45" stroke="#22225a" stroke-width="1"/>
600                <text x="{}" y="{}" fill="#8be9fd" font-size="14" font-weight="bold">◈ {}</text>"##,
601            f(T_MARGIN - 12.0),
602            f(*by),
603            f(content_w + 24.0),
604            f(*bh),
605            f(T_MARGIN),
606            f(by + 26.0),
607            esc(label)
608        );
609    }
610
611    // Cards
612    for pl in &placed {
613        body.push_str(&t_card(pl.fc, pl.x, pl.y, pl.h));
614    }
615
616    // Per-file globals/uses footnote
617    for (fs, (_, _, by, bh)) in p.files.iter().zip(file_bands.iter()) {
618        if fs.globals.is_empty() && fs.uses.is_empty() {
619            continue;
620        }
621        let fy = by + bh - 8.0;
622        let mut parts: Vec<String> = fs
623            .globals
624            .iter()
625            .map(|(n, v)| format!("◇ {n}={v}"))
626            .collect();
627        parts.extend(fs.uses.iter().map(|u| format!("use {u}")));
628        let _ = write!(
629            body,
630            r##"<text x="{}" y="{}" fill="#52528a" font-size="10">{}</text>"##,
631            f(T_MARGIN),
632            f(fy),
633            esc(&parts.join("    "))
634        );
635    }
636
637    svg_doc(w, h, &bg_rect(w, h, BG), &body)
638}
639
640const DEFS_T: &str = r##"<defs>
641  <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>
642  <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>
643</defs>"##;
644
645fn t_card(fc: &Func, x: f32, y: f32, h: f32) -> String {
646    let dominant = fc
647        .calls
648        .iter()
649        .max_by_key(|c| c.count)
650        .map(|c| c.cat.color())
651        .unwrap_or("#6ab0f5");
652    let border = if fc.is_entry { "#ffd700" } else { dominant };
653    let bw = if fc.is_entry { 2.5 } else { 1.2 };
654    let glow = if fc.is_entry {
655        r##" filter="url(#glow-g)""##
656    } else {
657        ""
658    };
659    let mut s = String::new();
660    let _ = write!(
661        s,
662        r##"<rect x="{}" y="{}" width="{}" height="{}" rx="8" fill="#13132e" stroke="{}" stroke-width="{}"{}/>
663           <rect x="{}" y="{}" width="4" height="{}" rx="2" fill="{}" opacity="0.7"/>"##,
664        f(x),
665        f(y),
666        f(T_CARD_W),
667        f(h),
668        border,
669        bw,
670        glow,
671        f(x + 2.0),
672        f(y + 2.0),
673        f(h - 4.0),
674        border
675    );
676
677    let name_y = y + 26.0;
678    let badge = if fc.is_entry {
679        format!(
680            r##"<text x="{}" y="{}" fill="#ffd700" font-size="9" font-weight="bold" text-anchor="end" opacity="0.85">⬡ ENTRY</text>"##,
681            f(x + T_CARD_W - 12.0),
682            f(name_y)
683        )
684    } else {
685        String::new()
686    };
687    let _ = write!(
688        s,
689        r##"<text x="{}" y="{}" fill="{}" font-size="14" font-weight="bold">{}</text>{}"##,
690        f(x + 16.0),
691        f(name_y),
692        if fc.is_entry { "#ffd700" } else { "#d0d0f0" },
693        esc(&fc.name),
694        badge
695    );
696
697    let stats_y = name_y + 18.0;
698    let params = if fc.params.is_empty() {
699        String::new()
700    } else {
701        format!("({})", fc.params.join(", "))
702    };
703    let mut st = Vec::new();
704    if fc.vtex > 0 {
705        st.push(format!("{} vtex", fc.vtex));
706    }
707    if fc.audio > 0 {
708        st.push(format!("{} audio", fc.audio));
709    }
710    if fc.crypto > 0 {
711        st.push(format!("{} crypto", fc.crypto));
712    }
713    if fc.physics > 0 {
714        st.push(format!("{} phys", fc.physics));
715    }
716    if fc.ai > 0 {
717        st.push(format!("{} ai", fc.ai));
718    }
719    if fc.has_loop {
720        st.push("↺ loop".into());
721    }
722    let _ = write!(
723        s,
724        r##"<text x="{}" y="{}" fill="#52528a" font-size="10">{}</text>
725           <text x="{}" y="{}" fill="#52528a" font-size="10" text-anchor="end">{}</text>
726           <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#22225a" stroke-width="1"/>"##,
727        f(x + 16.0),
728        f(stats_y),
729        esc(&params),
730        f(x + T_CARD_W - 12.0),
731        f(stats_y),
732        esc(&st.join("  ·  ")),
733        f(x + 12.0),
734        f(stats_y + 6.0),
735        f(x + T_CARD_W - 12.0),
736        f(stats_y + 6.0)
737    );
738
739    let iy0 = stats_y + 6.0 + 5.0 + T_ICON / 2.0;
740    let ix0 = x + 16.0 + T_ICON / 2.0;
741    let ir = T_ICON / 2.0 * 0.82;
742    for (i, c) in fc.calls.iter().enumerate() {
743        let row = i / T_ICONS_ROW;
744        let col = i % T_ICONS_ROW;
745        let ix = ix0 + col as f32 * (T_ICON + 5.0);
746        let iy = iy0 + row as f32 * (T_ICON + 5.0);
747        let _ = write!(
748            s,
749            r##"<rect x="{}" y="{}" width="{}" height="{}" rx="3" fill="{}" opacity="0.12"/>"##,
750            f(ix - T_ICON / 2.0),
751            f(iy - T_ICON / 2.0),
752            f(T_ICON),
753            f(T_ICON),
754            c.cat.color()
755        );
756        s.push_str(&icon(c.cat, ix, iy, ir));
757        if c.count > 1 {
758            let _ = write!(
759                s,
760                r##"<rect x="{}" y="{}" width="13" height="10" rx="3" fill="{}" opacity="0.9"/>
761                                  <text x="{}" y="{}" fill="#0a0a1a" font-size="8" font-weight="bold" text-anchor="middle">{}</text>"##,
762                f(ix + ir - 2.0),
763                f(iy - ir - 1.0),
764                c.cat.color(),
765                f(ix + ir + 4.5),
766                f(iy - ir + 7.0),
767                c.count
768            );
769        }
770    }
771    s
772}
773
774// ══════════════════════════════════════════════════════════════════════════════
775// 2. ARTWORK — text-free cubist sigil: translucent stars/polygons per file
776// ══════════════════════════════════════════════════════════════════════════════
777
778const A_CELL: f32 = 620.0;
779
780fn render_artwork(p: &Project) -> String {
781    let nf = p.files.len().max(1);
782    let cols = (nf as f32).sqrt().ceil() as usize;
783    let rows = (nf + cols - 1) / cols;
784    let w = cols as f32 * A_CELL;
785    let h = rows as f32 * A_CELL;
786
787    let mut body = String::new();
788    body.push_str(DEFS_A);
789
790    // Deep gradient field + a few huge faint background facets (cubist ground).
791    let _ = write!(
792        body,
793        r##"<rect width="{}" height="{}" fill="url(#agrad)"/>"##,
794        f(w),
795        f(h)
796    );
797    let seed = hash(&p.name);
798    for i in 0..7u64 {
799        let cx = frand(seed, i) * w;
800        let cy = frand(seed, i + 100) * h;
801        let r = 120.0 + frand(seed, i + 200) * 260.0;
802        let sides = 3 + (i % 5) as usize;
803        let hue = (i * 47) % 360;
804        let _ = write!(
805            body,
806            r##"<polygon points="{}" fill="hsl({},55%,55%)" opacity="0.05"/>"##,
807            ngon_points(cx, cy, r, sides, frand(seed, i + 300) * 6.28),
808            hue
809        );
810    }
811
812    // Per-file constellations.
813    let mut centroids: Vec<(f32, f32)> = Vec::new();
814    for (fi, fs) in p.files.iter().enumerate() {
815        let gx = (fi % cols) as f32 * A_CELL + A_CELL / 2.0;
816        let gy = (fi / cols) as f32 * A_CELL + A_CELL / 2.0;
817        centroids.push((gx, gy));
818        let fseed = hash(&fs.label);
819
820        // Each function = a node placed by golden-angle phyllotaxis around the cell
821        // centre; each of its calls = a translucent star/polygon around that node.
822        let ga = 2.399963f32; // golden angle
823        for (qi, fc) in fs.funcs.iter().enumerate() {
824            let rr = 36.0 + 150.0 * ((qi as f32 + 0.5) / fs.funcs.len().max(1) as f32).sqrt();
825            let na = qi as f32 * ga;
826            let fx = gx + rr * na.cos();
827            let fy = gy + rr * na.sin();
828
829            // faint tether to cell centre
830            let _ = write!(
831                body,
832                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#ffffff" stroke-width="1" opacity="0.06"/>"##,
833                f(gx),
834                f(gy),
835                f(fx),
836                f(fy)
837            );
838
839            let shapes = fc
840                .calls
841                .iter()
842                .map(|c| c.count)
843                .sum::<usize>()
844                .max(1)
845                .min(14);
846            let fnseed = hash(&fc.name) ^ fseed;
847            let pal: Vec<&str> = if fc.calls.is_empty() {
848                vec!["#6ab0f5"]
849            } else {
850                fc.calls.iter().map(|c| c.cat.color()).collect()
851            };
852            for si in 0..shapes {
853                let ang = frand(fnseed, si as u64) * 6.2831853;
854                let dist = frand(fnseed, si as u64 + 7) * 30.0;
855                let sx = fx + dist * ang.cos();
856                let sy = fy + dist * ang.sin();
857                let ro = 14.0
858                    + frand(fnseed, si as u64 + 11) * 34.0
859                    + if fc.is_entry { 16.0 } else { 0.0 };
860                let col = pal[si % pal.len()];
861                let cat = fc
862                    .calls
863                    .get(si % fc.calls.len().max(1))
864                    .map(|c| c.cat)
865                    .unwrap_or(Cat::User);
866                let sides = shape_sides(cat, &fc.name);
867                let rot = frand(fnseed, si as u64 + 23) * 6.2831853;
868                let op = 0.28 + frand(fnseed, si as u64 + 31) * 0.30;
869                // alternate filled star vs polygon for rhythm
870                let pts = if si % 2 == 0 {
871                    star_points(sx, sy, ro, ro * 0.45, sides, rot)
872                } else {
873                    ngon_points(sx, sy, ro, sides, rot)
874                };
875                let _ = write!(
876                    body,
877                    r##"<polygon points="{}" fill="{}" opacity="{:.2}" stroke="{}" stroke-width="0.6" stroke-opacity="0.4"/>"##,
878                    pts, col, op, col
879                );
880            }
881            // bright core for entry points
882            if fc.is_entry {
883                let _ = write!(
884                    body,
885                    r##"<circle cx="{}" cy="{}" r="9" fill="#ffd700" opacity="0.9"/>"##,
886                    f(fx),
887                    f(fy)
888                );
889            }
890        }
891    }
892
893    // Faint cross-file relationship arcs between cell centroids (program weave).
894    for (i, &(x0, y0)) in centroids.iter().enumerate() {
895        for &(x1, y1) in centroids.iter().skip(i + 1) {
896            let mx = (x0 + x1) / 2.0;
897            let my = (y0 + y1) / 2.0 - 60.0;
898            let _ = write!(
899                body,
900                r##"<path d="M {},{} Q {},{} {},{}" fill="none" stroke="#ffffff" stroke-width="0.8" opacity="0.05"/>"##,
901                f(x0),
902                f(y0),
903                f(mx),
904                f(my),
905                f(x1),
906                f(y1)
907            );
908        }
909    }
910
911    svg_doc(w, h, "", &body)
912}
913
914const DEFS_A: &str = r##"<defs>
915  <radialGradient id="agrad" cx="50%" cy="42%" r="75%">
916    <stop offset="0%" stop-color="#171733"/><stop offset="55%" stop-color="#0c0c1e"/><stop offset="100%" stop-color="#050510"/>
917  </radialGradient>
918</defs>"##;
919
920// ══════════════════════════════════════════════════════════════════════════════
921// 3. LING — harmonious file panels of colour-coded named tiles
922// ══════════════════════════════════════════════════════════════════════════════
923
924const L_MARGIN: f32 = 40.0;
925const L_TILE_W: f32 = 188.0;
926const L_TILE_H: f32 = 84.0;
927const L_TILE_GAP: f32 = 16.0;
928const L_PANEL_PAD: f32 = 22.0;
929const L_TITLE_H: f32 = 92.0;
930
931fn render_ling(p: &Project) -> String {
932    // Choose a tile column count that keeps panels balanced (square-ish).
933    let max_funcs = p
934        .files
935        .iter()
936        .map(|f| f.funcs.len())
937        .max()
938        .unwrap_or(1)
939        .max(1);
940    let tiles_per_row = ((max_funcs as f32).sqrt().ceil() as usize).clamp(2, 6);
941    let panel_w = L_PANEL_PAD * 2.0 + tiles_per_row as f32 * (L_TILE_W + L_TILE_GAP) - L_TILE_GAP;
942    let w = panel_w + L_MARGIN * 2.0;
943
944    // Layout panels top-to-bottom, centred.
945    let mut panels: Vec<(usize, f32, f32)> = Vec::new(); // (file index, y, height)
946    let mut y = L_TITLE_H + 12.0;
947    for (fi, fs) in p.files.iter().enumerate() {
948        let n = fs.funcs.len().max(1);
949        let rows = (n + tiles_per_row - 1) / tiles_per_row;
950        let body_h = rows as f32 * (L_TILE_H + L_TILE_GAP) - L_TILE_GAP;
951        let glob_h = if fs.globals.is_empty() && fs.uses.is_empty() {
952            0.0
953        } else {
954            30.0
955        };
956        let ph = 44.0 + body_h + glob_h + L_PANEL_PAD;
957        panels.push((fi, y, ph));
958        y += ph + 26.0;
959    }
960    let h = y + L_MARGIN;
961
962    let mut body = String::new();
963    body.push_str(DEFS_L);
964
965    // Title
966    let _ = write!(
967        body,
968        r##"<text x="{}" y="42" fill="#ffd700" font-size="11" font-weight="bold" letter-spacing="3" opacity="0.75">灵 · LING · AST</text>
969            <text x="{}" y="78" fill="#e6e6ff" font-size="30" font-weight="bold">{}</text>
970            <text x="{}" y="78" fill="#6a6aa0" font-size="13" text-anchor="end">{}</text>"##,
971        f(L_MARGIN),
972        f(L_MARGIN),
973        esc(&p.name),
974        f(w - L_MARGIN),
975        esc(&p.subtitle())
976    );
977
978    for (fi, py, ph) in &panels {
979        let fs = &p.files[*fi];
980        let px = L_MARGIN;
981        // Panel frame
982        let _ = write!(
983            body,
984            r##"<rect x="{}" y="{}" width="{}" height="{}" rx="16" fill="#101028" stroke="#26264e" stroke-width="1.2"/>
985                <rect x="{}" y="{}" width="{}" height="34" rx="16" fill="#16163a"/>
986                <text x="{}" y="{}" fill="#9bd8ff" font-size="15" font-weight="bold">▦ {}</text>
987                <text x="{}" y="{}" fill="#6a6aa0" font-size="11" text-anchor="end">{} fns</text>"##,
988            f(px),
989            f(*py),
990            f(panel_w),
991            f(*ph),
992            f(px),
993            f(*py),
994            f(panel_w),
995            f(px + 16.0),
996            f(py + 23.0),
997            esc(&fs.label),
998            f(px + panel_w - 14.0),
999            f(py + 23.0),
1000            fs.funcs.len()
1001        );
1002
1003        // Tiles
1004        let t0y = py + 44.0;
1005        for (i, fc) in fs.funcs.iter().enumerate() {
1006            let row = i / tiles_per_row;
1007            let col = i % tiles_per_row;
1008            let tx = px + L_PANEL_PAD + col as f32 * (L_TILE_W + L_TILE_GAP);
1009            let ty = t0y + row as f32 * (L_TILE_H + L_TILE_GAP);
1010            body.push_str(&l_tile(fc, tx, ty));
1011        }
1012
1013        // Globals / uses strip
1014        if !fs.globals.is_empty() || !fs.uses.is_empty() {
1015            let rows = (fs.funcs.len().max(1) + tiles_per_row - 1) / tiles_per_row;
1016            let gy = t0y + rows as f32 * (L_TILE_H + L_TILE_GAP) + 6.0;
1017            let mut parts: Vec<String> = fs
1018                .globals
1019                .iter()
1020                .map(|(n, v)| format!("◇ {n} = {v}"))
1021                .collect();
1022            parts.extend(fs.uses.iter().map(|u| format!("⇥ use {u}")));
1023            let _ = write!(
1024                body,
1025                r##"<text x="{}" y="{}" fill="#6a6aa0" font-size="11">{}</text>"##,
1026                f(px + L_PANEL_PAD),
1027                f(gy),
1028                esc(&parts.join("     "))
1029            );
1030        }
1031    }
1032
1033    svg_doc(w, h, &bg_rect(w, h, "#08081a"), &body)
1034}
1035
1036const DEFS_L: &str = r##"<defs>
1037  <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>
1038</defs>"##;
1039
1040fn l_tile(fc: &Func, x: f32, y: f32) -> String {
1041    let dom = fc
1042        .calls
1043        .iter()
1044        .max_by_key(|c| c.count)
1045        .map(|c| c.cat)
1046        .unwrap_or(Cat::User);
1047    let col = if fc.is_entry { "#ffd700" } else { dom.color() };
1048    let mut s = String::new();
1049    let _ = write!(
1050        s,
1051        r##"<rect x="{}" y="{}" width="{}" height="{}" rx="12" fill="#191940" stroke="{}" stroke-width="{}" filter="url(#tile-sh)"/>
1052           <rect x="{}" y="{}" width="{}" height="7" rx="3" fill="{}"/>"##,
1053        f(x),
1054        f(y),
1055        f(L_TILE_W),
1056        f(L_TILE_H),
1057        col,
1058        if fc.is_entry { 2.4 } else { 1.2 },
1059        f(x + 10.0),
1060        f(y + 12.0),
1061        f(L_TILE_W - 20.0),
1062        col
1063    );
1064
1065    // Name (truncated to fit)
1066    let name = if fc.name.chars().count() > 20 {
1067        format!("{}…", fc.name.chars().take(19).collect::<String>())
1068    } else {
1069        fc.name.clone()
1070    };
1071    let _ = write!(
1072        s,
1073        r##"<text x="{}" y="{}" fill="{}" font-size="14" font-weight="bold">{}{}</text>"##,
1074        f(x + 12.0),
1075        f(y + 38.0),
1076        if fc.is_entry { "#ffd700" } else { "#e0e0ff" },
1077        if fc.is_entry { "⬡ " } else { "" },
1078        esc(&name)
1079    );
1080
1081    // Category dots (one per distinct call category, up to 7)
1082    let mut seen = HashSet::new();
1083    let mut dots: Vec<Cat> = Vec::new();
1084    for c in &fc.calls {
1085        if seen.insert(c.cat) {
1086            dots.push(c.cat);
1087        }
1088    }
1089    for (i, c) in dots.iter().take(7).enumerate() {
1090        let _ = write!(
1091            s,
1092            r##"<circle cx="{}" cy="{}" r="4.5" fill="{}"/>"##,
1093            f(x + 16.0 + i as f32 * 13.0),
1094            f(y + 56.0),
1095            c.color()
1096        );
1097    }
1098
1099    // Stats line
1100    let mut st = Vec::new();
1101    if !fc.params.is_empty() {
1102        st.push(format!("{}p", fc.params.len()));
1103    }
1104    if fc.calls.len() > 0 {
1105        st.push(format!(
1106            "{} calls",
1107            fc.calls.iter().map(|c| c.count).sum::<usize>()
1108        ));
1109    }
1110    if fc.has_loop {
1111        st.push("↺".into());
1112    }
1113    let _ = write!(
1114        s,
1115        r##"<text x="{}" y="{}" fill="#7a7ab0" font-size="10" text-anchor="end">{}</text>"##,
1116        f(x + L_TILE_W - 12.0),
1117        f(y + L_TILE_H - 10.0),
1118        esc(&st.join("  "))
1119    );
1120    s
1121}