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 = 64.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)]
44pub(crate) enum Cat {
45    // ── Surface textures (vtex_*) ──
46    Rings, Spiral, Star, Flower, Lotus, Chakra, Yantra,
47    Hyper, Tess, Rain, Grid, Halftone, Pagoda, Torii, Cog,
48    // ── Procedural / fractal textures (tex_*) ──
49    Fractal,
50    // ── Audio ──
51    Tone, Vol, Listen, Sfx, Spectrum,
52    // ── Music / MIDI ──
53    Music, Note,
54    // ── Cryptography ──
55    Hash, Cipher, Sign, Kem, Shard,
56    // ── Physics ──
57    Rigid, Soft, Liquid, Force, Collide,
58    // ── 3D mesh & geometry ──
59    Mesh, Draw3D, Draw2D,
60    // ── Math ──
61    Trig, MathFn, Noise,
62    // ── Networking & AI ──
63    Net, Neural, Behavior,
64    // ── UI / dialog / holograms ──
65    Widget, Hud, Dialog, Holo,
66    // ── Text & vector ──
67    Str, Font, Vector,
68    // ── Colour, shading, scene ──
69    Color, Shade, Camera, Light, Fill, Present, Window,
70    // ── Input ──
71    Key, Mouse,
72    // ── IO / system / timing ──
73    Print, File, Sys, Anim,
74    // ── Generic ──
75    User, Loop, Const,
76}
77
78impl Cat {
79    pub(crate) fn color(self) -> &'static str {
80        match self {
81            // Surface textures — neon spectrum
82            Cat::Rings    => "#00e5ff",
83            Cat::Spiral   => "#00ffb3",
84            Cat::Star     => "#ffd700",
85            Cat::Flower   => "#ff79c6",
86            Cat::Lotus    => "#ff5e8a",
87            Cat::Chakra   => "#bd93f9",
88            Cat::Yantra   => "#ffb86c",
89            Cat::Hyper    => "#5575c8",
90            Cat::Tess     => "#50fa7b",
91            Cat::Rain     => "#f1fa8c",
92            Cat::Grid     => "#8be9fd",
93            Cat::Halftone => "#888899",
94            Cat::Pagoda   => "#ff9a3c",
95            Cat::Torii    => "#ff5e5e",
96            Cat::Cog      => "#b0b0c8",
97            // Procedural / fractal — violet
98            Cat::Fractal  => "#c77dff",
99            // Audio — magenta family
100            Cat::Tone     => "#ff1493",
101            Cat::Vol      => "#ff69b4",
102            Cat::Listen   => "#db77d9",
103            Cat::Sfx      => "#ff85c0",
104            Cat::Spectrum => "#ff4fa3",
105            // Music — purple
106            Cat::Music    => "#b388ff",
107            Cat::Note     => "#d8a7ff",
108            // Crypto — jade/teal family
109            Cat::Hash     => "#0fd9b0",
110            Cat::Cipher   => "#12b48c",
111            Cat::Sign     => "#5ce0c0",
112            Cat::Kem      => "#2aa98a",
113            Cat::Shard    => "#7ff0d8",
114            // Physics — warm / amber + water
115            Cat::Rigid    => "#ff8c42",
116            Cat::Soft     => "#ffb37b",
117            Cat::Liquid   => "#4aa3ff",
118            Cat::Force    => "#ff6b35",
119            Cat::Collide  => "#ff4d4d",
120            // 3D / draw — blue
121            Cat::Mesh     => "#6aa9ff",
122            Cat::Draw3D   => "#8fb8ff",
123            Cat::Draw2D   => "#5fd0ff",
124            // Math — lime
125            Cat::Trig     => "#b6e36b",
126            Cat::MathFn   => "#d4e157",
127            Cat::Noise    => "#9ccc65",
128            // Net / AI
129            Cat::Net      => "#18b6e0",
130            Cat::Neural   => "#ffd54f",
131            Cat::Behavior => "#ffca6b",
132            // UI / dialog / holo
133            Cat::Widget   => "#7a8fb0",
134            Cat::Hud      => "#92a8c8",
135            Cat::Dialog   => "#e0b97d",
136            Cat::Holo     => "#66ffe0",
137            // Text & vector
138            Cat::Str      => "#a0b4d0",
139            Cat::Font     => "#c0c8e0",
140            Cat::Vector   => "#88d8c0",
141            // Colour / shading / scene
142            Cat::Color    => "#ff7eb6",
143            Cat::Shade    => "#9d8df1",
144            Cat::Camera   => "#ffe066",
145            Cat::Light    => "#ffe4b5",
146            Cat::Fill     => "#3a3a6a",
147            Cat::Present  => "#555580",
148            Cat::Window   => "#7fd4ff",
149            // Input
150            Cat::Key      => "#ff9e7d",
151            Cat::Mouse    => "#ffbfa3",
152            // IO / system / timing
153            Cat::Print    => "#9aa0b5",
154            Cat::File     => "#7d8aa8",
155            Cat::Sys      => "#6b7488",
156            Cat::Anim     => "#ffd28a",
157            // Generic
158            Cat::User     => "#6ab0f5",
159            Cat::Loop     => "#ff7f50",
160            Cat::Const    => "#50fa7b",
161        }
162    }
163
164    pub(crate) fn label(self) -> &'static str {
165        match self {
166            Cat::Rings    => "rings",
167            Cat::Spiral   => "spiral",
168            Cat::Star     => "star",
169            Cat::Flower   => "flower",
170            Cat::Lotus    => "lotus",
171            Cat::Chakra   => "chakra",
172            Cat::Yantra   => "yantra",
173            Cat::Hyper    => "hyperbolic",
174            Cat::Tess     => "tessellated",
175            Cat::Rain     => "letter rain",
176            Cat::Grid     => "grid",
177            Cat::Halftone => "halftone",
178            Cat::Pagoda   => "pagoda",
179            Cat::Torii    => "torii",
180            Cat::Cog      => "spiked cog",
181            Cat::Fractal  => "fractal tex",
182            Cat::Tone     => "audio tone",
183            Cat::Vol      => "audio vol",
184            Cat::Listen   => "listener",
185            Cat::Sfx      => "sfx",
186            Cat::Spectrum => "spectrum",
187            Cat::Music    => "music",
188            Cat::Note     => "note",
189            Cat::Hash     => "hash",
190            Cat::Cipher   => "cipher",
191            Cat::Sign     => "signature",
192            Cat::Kem      => "key exch",
193            Cat::Shard    => "secret share",
194            Cat::Rigid    => "rigid body",
195            Cat::Soft     => "soft body",
196            Cat::Liquid   => "liquid",
197            Cat::Force    => "force",
198            Cat::Collide  => "collision",
199            Cat::Mesh     => "mesh",
200            Cat::Draw3D   => "draw 3d",
201            Cat::Draw2D   => "draw 2d",
202            Cat::Trig     => "trig",
203            Cat::MathFn   => "math",
204            Cat::Noise    => "noise",
205            Cat::Net      => "network",
206            Cat::Neural   => "neural net",
207            Cat::Behavior => "behavior tree",
208            Cat::Widget   => "ui widget",
209            Cat::Hud      => "hud",
210            Cat::Dialog   => "dialog",
211            Cat::Holo     => "hologram",
212            Cat::Str      => "string",
213            Cat::Font     => "font",
214            Cat::Vector   => "svg vector",
215            Cat::Color    => "color",
216            Cat::Shade    => "shading",
217            Cat::Camera   => "camera",
218            Cat::Light    => "light",
219            Cat::Fill     => "fill",
220            Cat::Present  => "render",
221            Cat::Window   => "window",
222            Cat::Key      => "keyboard",
223            Cat::Mouse    => "mouse",
224            Cat::Print    => "print",
225            Cat::File     => "file io",
226            Cat::Sys      => "system",
227            Cat::Anim     => "animation",
228            Cat::User     => "fn call",
229            Cat::Loop     => "loop",
230            Cat::Const    => "const",
231        }
232    }
233
234    /// Coarse domain a category belongs to — used for grouped legends & stats.
235    pub(crate) fn domain(self) -> &'static str {
236        if is_vtex(self)    { return "texture"; }
237        if is_audio(self)   { return "audio"; }
238        if is_crypto(self)  { return "crypto"; }
239        if is_physics(self) { return "physics"; }
240        if is_ai(self)      { return "ai"; }
241        match self {
242            Cat::Music | Cat::Note                                  => "music",
243            Cat::Mesh | Cat::Draw3D | Cat::Draw2D | Cat::Vector     => "geometry",
244            Cat::Trig | Cat::MathFn | Cat::Noise | Cat::Fractal     => "math",
245            Cat::Widget | Cat::Hud | Cat::Dialog | Cat::Holo        => "ui",
246            Cat::Str | Cat::Font                                    => "text",
247            Cat::Color | Cat::Shade | Cat::Camera | Cat::Light
248                | Cat::Fill | Cat::Present | Cat::Window            => "scene",
249            Cat::Key | Cat::Mouse                                   => "input",
250            Cat::Print | Cat::File | Cat::Sys | Cat::Anim | Cat::Net=> "system",
251            _                                                       => "code",
252        }
253    }
254}
255
256pub(crate) fn categorize(name: &str) -> Cat {
257    // ── Surface textures: vtex_* (+ Thai ลาย* / CJK aliases) ──
258    if name.starts_with("vtex_rings")       || name == "ลายวงซ้อน" || name == "纹环" || name == "輪模様" || name == "輪무늬" { return Cat::Rings; }
259    if name.starts_with("vtex_spiral")      || name == "ลายก้นหอย" || name == "ลายเกลียว" || name == "ลายเกลียวหมุน" || name == "纹螺" || name == "螺旋模様" || name == "나선무늬" { return Cat::Spiral; }
260    if name.starts_with("vtex_star")        || name == "ลายดาว" || name == "纹星" || name == "星模様" || name == "별무늬" { return Cat::Star; }
261    if name.starts_with("vtex_flower")      || name == "ลายดอกไม้" || name == "ลายดอก" || name == "纹花" || name == "花模様" || name == "꽃무늬" { return Cat::Flower; }
262    if name.starts_with("vtex_lotus")       || name == "ลายบัว" || name == "ลายดอกบัว" || name == "纹莲" || name == "蓮模様" || name == "연꽃무늬" { return Cat::Lotus; }
263    if name.starts_with("vtex_chakra")      || name == "ลายจักร" || name == "纹轮" || name == "輪模様" || name == "바퀴무늬" { return Cat::Chakra; }
264    if name.starts_with("vtex_yantra")      || name == "ลายยันต์" || name == "纹咒" || name == "護符模様" || name == "부적무늬" { return Cat::Yantra; }
265    if name.starts_with("vtex_hyper")       || name == "ลายไฮเพอร์โบลิก" || name == "ลายไฮเปอร์" || name == "纹超" || name == "超次元模様" || name == "초차원무늬" { return Cat::Hyper; }
266    if name.starts_with("vtex_tessellated") || name == "ลายตาข่าย" || name == "纹镶嵌" || name == "網目模様" { return Cat::Tess; }
267    if name.starts_with("vtex_letter_rain") || name.starts_with("vtex_rain") || name == "ลายอักษรไหล" || name == "ลายฝน" || name == "纹雨" || name == "文字雨" || name == "비무늬" { return Cat::Rain; }
268    if name.starts_with("vtex_grid")        || name == "ลายตาราง" || name == "纹格" || name == "格子模様" || name == "격자무늬" { return Cat::Grid; }
269    if name.starts_with("vtex_halftone")    || name == "ลายจุด" || name == "ลายฮาล์ฟโทน" || name == "纹半调" || name == "網点模様" || name == "망점" { return Cat::Halftone; }
270    if name.starts_with("vtex_pagoda")      || name == "ลายเจดีย์" || name == "เจดีย์" || name == "纹塔" || name == "탑" { return Cat::Pagoda; }
271    if name.starts_with("vtex_torii")       || name == "ประตูโทริอิ" || name == "纹鸟居" || name == "鳥居" || name == "도리이" { return Cat::Torii; }
272    if name.starts_with("vtex_spiked_cog")  || name == "ฟันเฟืองหนาม" || name == "纹棘轮" || name == "歯車模様" || name == "톱니바퀴" { return Cat::Cog; }
273    if name.starts_with("vtex_")            { return Cat::Tess; }
274
275    // ── Procedural / fractal textures: tex_* ──
276    if name.starts_with("tex_") { return Cat::Fractal; }
277
278    // ── Audio ──
279    if name == "audio_tone" || name == "เสียงโทน" || name == "音調" || name == "音调" || name == "음조" { return Cat::Tone; }
280    if name == "audio_volume" || name == "audio_bgm_volume" || name == "ระดับเสียง" || name == "音量" || name == "음량" { return Cat::Vol; }
281    if name == "audio_listener" || name == "音声リスナー" || name == "오디오리스너" || name == "音频监听" { return Cat::Listen; }
282    if name.starts_with("audio_") { return Cat::Sfx; }
283    if name.starts_with("mic_") || name.starts_with("fft_") { return Cat::Spectrum; }
284
285    // ── Music / MIDI ──
286    if name == "music_note_on" || name == "music_note_off" || name == "music_note" || name == "music_note_name" { return Cat::Note; }
287    if name.starts_with("music_") || name.starts_with("midi_") || name.starts_with("MIDI") { return Cat::Music; }
288
289    // ── Cryptography ──
290    if name == "crypto_hash" || name == "sha3_512" || name == "blake3" || name == "hash_int" || name == "hash_str" { return Cat::Hash; }
291    if name == "crypto_seal" || name == "crypto_open" || name == "encrypt" || name == "aes_gcm_256" { return Cat::Cipher; }
292    if name == "ed25519" || name == "schnorr_verify" || name == "vrf_verify" || name == "derive" || name == "argon2id" { return Cat::Sign; }
293    if name == "mlkem768" || name.starts_with("hybrid_") || name.starts_with("knot_") { return Cat::Kem; }
294    if name == "shamir_reconstruct" { return Cat::Shard; }
295
296    // ── Physics ──
297    if name.starts_with("rb_") || name == "rigidbody" { return Cat::Rigid; }
298    if name.starts_with("soft_") { return Cat::Soft; }
299    if name.starts_with("liquid_") { return Cat::Liquid; }
300    if matches!(name, "force"|"torque"|"spring"|"friction"|"elasticity"|"acceleration"|"apply_impulse"|"gyro") { return Cat::Force; }
301    if matches!(name, "collision"|"raycast"|"aabb"|"AABB"|"constraint") { return Cat::Collide; }
302
303    // ── 3D mesh & primitives ──
304    if matches!(name, "cube"|"box"|"capsule"|"capsule_chain"|"cylinder"|"pyramid"|"icosphere"|"icosahedron"|"octahedron"|"orb_shell"|"stairs"|"frustum") { return Cat::Mesh; }
305    if matches!(name, "draw_line_3d"|"draw_triangle_3d"|"line3d"|"triangle3d"|"project_3d"|"render_3d"|"flush_3d"|"font_text_3d") { return Cat::Draw3D; }
306
307    // ── Neural / behavior / dialog (AI) ──
308    if name.starts_with("nn_") { return Cat::Neural; }
309    if name.starts_with("bt_") { return Cat::Behavior; }
310    if name.starts_with("dialog_") { return Cat::Dialog; }
311
312    // ── Networking ──
313    if name.starts_with("net_") { return Cat::Net; }
314
315    // ── UI widgets / HUD ──
316    if matches!(name, "ui_radar"|"ui_radar3d"|"ui_minimap"|"ui_compass"|"ui_healthbar"|"ui_reticle"|"ui_target"|"ui_gauge"|"ui_gauge3d"|"ui_vu"|"ui_battery"|"ui_segbar"|"ui_bar"|"ui_progress"|"ui_cooldown"|"ui_scanlines"|"ui_vignette"|"ui_spark"|"ui_ring"|"ui_counter") { return Cat::Hud; }
317    if name.starts_with("ui_") { return Cat::Widget; }
318
319    // ── Holograms / particles ──
320    if matches!(name, "holo_points"|"holo_fragment_count"|"knot_points"|"sparkle"|"neon"|"psychedelic") { return Cat::Holo; }
321
322    // ── Fonts & vector text ──
323    if name.starts_with("font_") { return Cat::Font; }
324    if name.starts_with("svg_") { return Cat::Vector; }
325
326    // ── Strings ──
327    if name.starts_with("str_") || matches!(name, "split"|"join"|"trim"|"substr"|"format"|"to_str"|"num_str"|"starts_with"|"ends_with") { return Cat::Str; }
328
329    // ── Math: trig / noise / general ──
330    if matches!(name, "sin"|"cos"|"tan"|"asin"|"acos"|"atan"|"atan2"|"arcsin"|"arccos"|"arctan"|"arctan2"|"tanh"|"tanhf"|"hypot") { return Cat::Trig; }
331    if matches!(name, "perlin"|"perlin3"|"noise2"|"fbm"|"vnoise"|"smoothstep") { return Cat::Noise; }
332    if matches!(name, "sqrt"|"cbrt"|"pow"|"exp"|"ln"|"log"|"log2"|"log10"|"abs"|"floor"|"ceil"|"round"|"trunc"|"fract"|"sign"|"clamp"|"lerp"|"min"|"max"|"rand"|"pi"|"tau") { return Cat::MathFn; }
333
334    // ── Colour & shading ──
335    if matches!(name, "hsl_color"|"hsv_to_rgb"|"set_color"|"set_color_hsl"|"color"|"lerp_color"|"gfx_color") { return Cat::Color; }
336    if matches!(name, "set_shade_mode"|"set_rim"|"set_fog"|"set_ambient"|"set_cel_bands"|"set_blend"|"set_shadow_color"|"set_projection"|"set_zdist") { return Cat::Shade; }
337
338    // ── Camera / lights ──
339    if matches!(name, "set_camera"|"set_camera_pos"|"move_camera") { return Cat::Camera; }
340    if matches!(name, "add_light"|"clear_lights") { return Cat::Light; }
341
342    // ── Window / present / fill ──
343    if matches!(name, "open_window"|"open_fullscreen"|"gfx_window"|"fullscreen"|"windowed"|"wait_window"|"is_open"|"window_is_open"|"gfx_is_open"|"gfx_wait"|"resolution"|"get_width"|"get_height") { return Cat::Window; }
344    if matches!(name, "present"|"แสดงผล"|"gfx_present"|"render"|"render_3d"|"flush_3d") { return Cat::Present; }
345    if matches!(name, "fill"|"เติม"|"gfx_fill"|"clear") { return Cat::Fill; }
346
347    // ── 2D drawing ──
348    if name.starts_with("draw_") || matches!(name, "gfx_line"|"gfx_pixel"|"gfx_triangle"|"line"|"triangle"|"pixel") { return Cat::Draw2D; }
349
350    // ── Input ──
351    if name.starts_with("mouse_") || matches!(name, "capture_mouse"|"release_mouse") { return Cat::Mouse; }
352    if name.starts_with("key_") || matches!(name, "keys"|"text_poll"|"text_get") { return Cat::Key; }
353
354    // ── IO / system ──
355    if matches!(name, "print"|"println"|"print_file"|"imprimir"|"afficher"|"вывести"|"พิมพ์"|"打印") { return Cat::Print; }
356    if matches!(name, "read_file"|"write_file"|"อ่านไฟล์"|"เขียนไฟล์") { return Cat::File; }
357    if matches!(name, "system"|"get_args"|"time_now"|"sleep"|"sleep_ms") { return Cat::Sys; }
358
359    // ── Animation / timing (Anima organic drivers + classic timing) ──
360    if matches!(name, "animation"|"ease"|"tick"|"delta_time"|"frame_count"|"frame"|"record_frame"|"record_count"
361        |"tween"|"tween_ease"|"breathe"|"wobble"|"gait_phase"|"gait_swing"|"gait_lift"|"spring_to"|"ik2") { return Cat::Anim; }
362    // ── Anima mechanical drivers — reuse the gear (Cog) glyph ──
363    if matches!(name, "gear_couple"|"gear_train"|"cam_lift"|"piston"|"rack") { return Cat::Cog; }
364
365    Cat::User
366}
367
368pub(crate) fn is_vtex(c: Cat) -> bool {
369    matches!(c, Cat::Rings|Cat::Spiral|Cat::Star|Cat::Flower|Cat::Lotus|
370               Cat::Chakra|Cat::Yantra|Cat::Hyper|Cat::Tess|Cat::Rain|
371               Cat::Grid|Cat::Halftone|Cat::Pagoda|Cat::Torii|Cat::Cog)
372}
373pub(crate) fn is_audio(c: Cat) -> bool {
374    matches!(c, Cat::Tone|Cat::Vol|Cat::Listen|Cat::Sfx|Cat::Spectrum)
375}
376pub(crate) fn is_crypto(c: Cat) -> bool {
377    matches!(c, Cat::Hash|Cat::Cipher|Cat::Sign|Cat::Kem|Cat::Shard)
378}
379pub(crate) fn is_physics(c: Cat) -> bool {
380    matches!(c, Cat::Rigid|Cat::Soft|Cat::Liquid|Cat::Force|Cat::Collide)
381}
382pub(crate) fn is_ai(c: Cat) -> bool {
383    matches!(c, Cat::Neural|Cat::Behavior)
384}
385
386const ENTRY_NAMES: &[&str] = &[
387    "start","main","启","เริ่ม","시작","начать","начало",
388    "inicio","comenzar","début","commencer","anfang","starten","início",
389];
390fn is_entry(name: &str) -> bool { ENTRY_NAMES.contains(&name) }
391
392// ── Data model ────────────────────────────────────────────────────────────────
393
394struct GlobalConst { name: String, value: String }
395
396#[derive(Clone)]
397struct CallItem { name: String, cat: Cat, count: usize }
398
399struct FuncCard {
400    name:      String,
401    params:    Vec<String>,
402    calls:     Vec<CallItem>,
403    has_loop:  bool,
404    is_entry:  bool,
405    vtex_count:  usize,
406    audio_count: usize,
407}
408
409struct Document {
410    filename: String,
411    globals:  Vec<GlobalConst>,
412    funcs:    Vec<FuncCard>,
413    #[allow(dead_code)]
414    fn_names: HashSet<String>,
415}
416
417// ── AST walking ───────────────────────────────────────────────────────────────
418
419struct RawCall { name: String, cat: Cat }
420
421fn walk_stmts(stmts: &[Stmt], fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
422    for s in stmts {
423        let e = match s { Stmt::Expr(e)|Stmt::Return(e) => e, Stmt::Bind(_,e) => e };
424        walk_expr(e, fns, out, loop_);
425    }
426}
427
428fn walk_expr(e: &Expr, fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
429    match e {
430        Expr::Call(func, args) => {
431            if let Expr::Ident(name) = func.as_ref() {
432                let cat = if fns.contains(name.as_str()) {
433                    let base = categorize(name);
434                    if base == Cat::User { Cat::User } else { base }
435                } else {
436                    categorize(name)
437                };
438                out.push(RawCall { name: name.clone(), cat });
439            }
440            for a in args { walk_expr(a, fns, out, loop_); }
441        }
442        Expr::While { cond, body } => {
443            *loop_ = true;
444            walk_expr(cond, fns, out, loop_);
445            walk_stmts(body, fns, out, loop_);
446        }
447        Expr::Do(ss) => walk_stmts(ss, fns, out, loop_),
448        Expr::If { cond, then, elseifs, else_body } => {
449            walk_expr(cond, fns, out, loop_);
450            walk_stmts(then, fns, out, loop_);
451            for (c,b) in elseifs { walk_expr(c,fns,out,loop_); walk_stmts(b,fns,out,loop_); }
452            if let Some(b) = else_body { walk_stmts(b, fns, out, loop_); }
453        }
454        Expr::For { iter, body, .. } => {
455            walk_expr(iter, fns, out, loop_);
456            walk_stmts(body, fns, out, loop_);
457        }
458        Expr::BinOp(_, a, b) => { walk_expr(a, fns, out, loop_); walk_expr(b, fns, out, loop_); }
459        Expr::Array(es) => { for a in es { walk_expr(a, fns, out, loop_); } }
460        _ => {}
461    }
462}
463
464fn aggregate(raw: Vec<RawCall>) -> Vec<CallItem> {
465    let mut out: Vec<CallItem> = Vec::new();
466    for r in raw {
467        if let Some(last) = out.last_mut() {
468            if last.name == r.name { last.count += 1; continue; }
469        }
470        out.push(CallItem { name: r.name, cat: r.cat, count: 1 });
471    }
472    out
473}
474
475fn make_card(name: String, params: Vec<String>, raw: Vec<RawCall>,
476             has_loop: bool, is_entry: bool) -> FuncCard {
477    let calls = aggregate(raw);
478    let vtex_count  = calls.iter().filter(|c| is_vtex(c.cat)).map(|c| c.count).sum();
479    let audio_count = calls.iter().filter(|c| is_audio(c.cat)).map(|c| c.count).sum();
480    FuncCard { name, params, calls, has_loop, is_entry, vtex_count, audio_count }
481}
482
483impl Document {
484    fn build(filename: &str, prog: &Program) -> Self {
485        let fn_names: HashSet<String> = prog.items.iter().filter_map(|i| {
486            if let Item::Fn(f) = i { Some(f.name.clone()) } else { None }
487        }).collect();
488
489        let mut globals = Vec::new();
490        let mut entries = Vec::new();
491        let mut funcs   = Vec::new();
492
493        for item in &prog.items {
494            match item {
495                Item::Bind(name, expr) => match expr {
496                    Expr::Number(n) => globals.push(GlobalConst {
497                        name: name.clone(),
498                        value: if n.fract() == 0.0 { format!("{}", *n as i64) }
499                               else { format!("{:.2}", n) },
500                    }),
501                    Expr::Do(body) => {
502                        let mut raw = Vec::new();
503                        let mut lp = false;
504                        // Scan inside the do block for a while loop too
505                        for s in body {
506                            if let Stmt::Expr(Expr::While { body: wb, .. }) = s {
507                                lp = true;
508                                walk_stmts(wb, &fn_names, &mut raw, &mut lp);
509                            }
510                        }
511                        walk_stmts(body, &fn_names, &mut raw, &mut lp);
512                        entries.push(make_card(name.clone(), vec![], raw, lp, is_entry(name)));
513                    }
514                    _ => {}
515                },
516                Item::Fn(f) => {
517                    let mut raw = Vec::new();
518                    let mut lp  = false;
519                    walk_stmts(&f.body, &fn_names, &mut raw, &mut lp);
520                    funcs.push(make_card(f.name.clone(), f.params.clone(), raw, lp, false));
521                }
522                _ => {}
523            }
524        }
525
526        entries.extend(funcs);
527        Document { filename: filename.to_string(), globals, funcs: entries, fn_names }
528    }
529}
530
531// ── SVG icons ─────────────────────────────────────────────────────────────────
532// Each icon is drawn centred at (cx, cy) within a radius of r≈10.
533
534fn xe(s: &str) -> String { s.replace('&',"&amp;").replace('<',"&lt;").replace('>',"&gt;") }
535fn p(v: f32) -> String { format!("{:.2}", v) }
536
537pub(crate) fn icon(cat: Cat, cx: f32, cy: f32, r: f32) -> String {
538    let c = cat.color();
539    match cat {
540        Cat::Rings => {
541            format!(
542                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.9"/>
543                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.65"/>
544                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.4"/>"#,
545                p(cx),p(cy),p(r), p(cx),p(cy),p(r*0.63), p(cx),p(cy),p(r*0.33)
546            )
547        }
548        Cat::Spiral => {
549            // Approximate spiral with two arcs
550            let (x0,y0) = (cx, cy - r*0.1);
551            let (x1,y1) = (cx + r*0.85, cy);
552            let (x2,y2) = (cx, cy + r*0.9);
553            let (x3,y3) = (cx - r*0.85, cy + r*0.1);
554            let (x4,y4) = (cx, cy - r*0.9);
555            format!(
556                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{} S {},{} {},{} S {},{} {},{}"
557                        fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>"#,
558                p(x0),p(y0),
559                p(cx+r),p(y0), p(x1),p(cy-r*0.5), p(x1),p(y1),
560                p(cx+r*0.3),p(y2), p(x2),p(y2),
561                p(x3),p(y3-r*0.4), p(x3),p(y3),
562                p(cx),p(y4), p(x4),p(y4)
563            )
564        }
565        Cat::Star => {
566            // 5-point star
567            let pts: String = (0..5).flat_map(|i| {
568                let ao = (i as f32 * 72.0 - 90.0).to_radians();
569                let ai = ao + 36.0_f32.to_radians();
570                let ri = r * 0.42;
571                vec![
572                    format!("{},{}", p(cx + r*ao.cos()), p(cy + r*ao.sin())),
573                    format!("{},{}", p(cx + ri*ai.cos()), p(cy + ri*ai.sin())),
574                ]
575            }).collect::<Vec<_>>().join(" ");
576            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.85"/>"#)
577        }
578        Cat::Flower => {
579            // 6 petals as ellipses
580            (0..6).map(|i| {
581                let angle = i as f32 * 60.0;
582                format!(
583                    r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
584                           fill="{c}" opacity="0.5"
585                           transform="rotate({angle},{},{})"/>"#,
586                    p(cx), p(cy - r*0.45), p(r*0.28), p(r*0.52),
587                    p(cx), p(cy)
588                )
589            }).collect::<String>()
590        }
591        Cat::Lotus => {
592            // 8 petals, more elongated
593            (0..8).map(|i| {
594                let angle = i as f32 * 45.0;
595                format!(
596                    r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
597                           fill="{c}" opacity="0.45"
598                           transform="rotate({angle},{},{})"/>"#,
599                    p(cx), p(cy - r*0.50), p(r*0.22), p(r*0.55),
600                    p(cx), p(cy)
601                )
602            }).collect::<String>()
603                + &format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
604                    p(cx), p(cy), p(r*0.22))
605        }
606        Cat::Chakra => {
607            // Central circle + 8 spokes
608            let spokes: String = (0..8).map(|i| {
609                let a = (i as f32 * 45.0).to_radians();
610                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.8"/>"#,
611                    p(cx + r*0.28*a.cos()), p(cy + r*0.28*a.sin()),
612                    p(cx + r*0.88*a.cos()), p(cy + r*0.88*a.sin()))
613            }).collect();
614            spokes + &format!(
615                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
616                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
617                p(cx), p(cy), p(r*0.88),
618                p(cx), p(cy), p(r*0.2)
619            )
620        }
621        Cat::Yantra => {
622            // Hexagram (Star of David) from two triangles
623            let h = r * 0.866;
624            let t1 = format!("{},{} {},{} {},{}",
625                p(cx), p(cy - r),
626                p(cx + h), p(cy + r*0.5),
627                p(cx - h), p(cy + r*0.5));
628            let t2 = format!("{},{} {},{} {},{}",
629                p(cx), p(cy + r),
630                p(cx - h), p(cy - r*0.5),
631                p(cx + h), p(cy - r*0.5));
632            format!(
633                r#"<polygon points="{t1}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
634                   <polygon points="{t2}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
635                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25"/>"#,
636                p(cx), p(cy), p(r*0.38)
637            )
638        }
639        Cat::Hyper => {
640            // Radial + concentric = Poincaré disc
641            let rays: String = (0..6).map(|i| {
642                let a = (i as f32 * 30.0).to_radians();
643                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"#,
644                    p(cx - r*a.cos()), p(cy - r*a.sin()),
645                    p(cx + r*a.cos()), p(cy + r*a.sin()))
646            }).collect();
647            rays + &format!(
648                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>
649                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.5"/>
650                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.3"/>"#,
651                p(cx),p(cy),p(r),   p(cx),p(cy),p(r*0.6),   p(cx),p(cy),p(r*0.3)
652            )
653        }
654        Cat::Tess => {
655            // Wavy horizontal lines
656            (0..4).map(|i| {
657                let y = cy - r*0.7 + i as f32 * r*0.46;
658                let amp = r * 0.15;
659                format!(
660                    r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
661                            fill="none" stroke="{c}" stroke-width="1.1" opacity="0.7"/>"#,
662                    p(cx-r), p(y),
663                    p(cx-r+r*0.4), p(y-amp),  p(cx-r*0.1), p(y-amp),  p(cx), p(y),
664                    p(cx+r*0.5), p(y+amp),  p(cx+r), p(y)
665                )
666            }).collect::<String>()
667        }
668        Cat::Rain => {
669            // Falling dashes (letter rain columns)
670            (0..5).flat_map(|col| {
671                let x = cx - r*0.9 + col as f32 * r*0.45;
672                (0..3).map(move |row| {
673                    let y = cy - r*0.8 + row as f32 * r*0.55;
674                    let opa = 0.9 - row as f32 * 0.22;
675                    format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="{:.2}"/>"#,
676                        p(x - r*0.06), p(y), p(r*0.12), p(r*0.30), opa)
677                })
678            }).collect::<String>()
679        }
680        Cat::Grid => {
681            // # hash grid
682            let n = 3;
683            let step = r * 2.0 / n as f32;
684            let mut s = String::new();
685            for i in 0..=n {
686                let off = -r + i as f32 * step;
687                write!(s, r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>
688                             <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>"#,
689                    p(cx+off),p(cy-r), p(cx+off),p(cy+r),
690                    p(cx-r),p(cy+off), p(cx+r),p(cy+off)).ok();
691            }
692            s
693        }
694        Cat::Halftone => {
695            // 4×4 dot grid
696            (0..4).flat_map(|row| (0..4).map(move |col| {
697                let x = cx - r*0.75 + col as f32 * r*0.5;
698                let y = cy - r*0.75 + row as f32 * r*0.5;
699                format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.6"/>"#,
700                    p(x), p(y), p(r*0.14))
701            })).collect::<String>()
702        }
703        Cat::Tone => {
704            // Sine wave
705            let x0 = cx - r; let x3 = cx + r; let xm = cx;
706            let amp = r * 0.65;
707            format!(
708                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
709                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>"#,
710                p(x0), p(cy),
711                p(x0 + r*0.5), p(cy - amp), p(xm - r*0.1), p(cy - amp), p(xm), p(cy),
712                p(xm + r*0.6), p(cy + amp), p(x3), p(cy)
713            )
714        }
715        Cat::Vol | Cat::Listen => {
716            // Concentric arcs (speaker/ear)
717            let arcs: String = (1..=3).map(|i| {
718                let ri = r * 0.3 * i as f32;
719                format!(
720                    r#"<path d="M {},{} A {ri},{ri} 0 0,1 {},{}"
721                            fill="none" stroke="{c}" stroke-width="1.3" opacity="{:.2}"/>"#,
722                    p(cx), p(cy - ri),
723                    p(cx), p(cy + ri),
724                    1.0 - i as f32 * 0.22
725                )
726            }).collect();
727            arcs + &format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.7"/>"#,
728                p(cx - r*0.15), p(cy), p(r*0.22))
729        }
730        Cat::Camera => {
731            // Lens: outer circle + inner circle + crosshair dot
732            format!(
733                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.8"/>
734                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.55"/>
735                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"#,
736                p(cx), p(cy), p(r),
737                p(cx), p(cy), p(r*0.6),
738                p(cx), p(cy), p(r*0.18)
739            )
740        }
741        Cat::Light => {
742            // Sunburst: central circle + 8 rays
743            let rays: String = (0..8).map(|i| {
744                let a = (i as f32 * 45.0).to_radians();
745                let r1 = r * 0.38;
746                let r2 = r * 0.90;
747                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.75"/>"#,
748                    p(cx + r1*a.cos()), p(cy + r1*a.sin()),
749                    p(cx + r2*a.cos()), p(cy + r2*a.sin()))
750            }).collect();
751            rays + &format!(r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"#,
752                p(cx), p(cy), p(r*0.32))
753        }
754        Cat::Fill => {
755            format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"#,
756                p(cx - r*0.9), p(cy - r*0.7), p(r*1.8), p(r*1.4))
757        }
758        Cat::Present => {
759            // Triangle (play/render button)
760            let pts = format!("{},{} {},{} {},{}",
761                p(cx - r*0.7), p(cy - r*0.8),
762                p(cx + r*0.8), p(cy),
763                p(cx - r*0.7), p(cy + r*0.8));
764            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.7"/>"#)
765        }
766        Cat::User => {
767            // Right-pointing chevron (function call arrow)
768            format!(
769                r#"<path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
770                         stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
771                   <path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
772                         stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>"#,
773                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),
774                p(cx), p(cy-r*0.7), p(cx+r*0.95), p(cy), p(cx), p(cy+r*0.7)
775            )
776        }
777        Cat::Loop => {
778            // Circular arrow
779            format!(
780                r#"<path d="M {},{} A {},{} 0 1,1 {},{}"
781                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
782                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"#,
783                p(cx), p(cy - r*0.9),
784                p(r*0.9), p(r*0.9),
785                p(cx + r*0.4), p(cy - r*0.9),
786                p(cx+r*0.4), p(cy-r*0.9),
787                p(cx+r*0.1), p(cy-r*0.55),
788                p(cx+r*0.8), p(cy-r*0.62)
789            )
790        }
791        // ── Surface textures (extra) ──────────────────────────────────────────
792        Cat::Pagoda => {
793            let mut s = String::new();
794            for i in 0..3 {
795                let yy = cy - r*0.7 + i as f32 * r*0.55;
796                let hw = r*(0.4 + i as f32*0.22);
797                write!(s, r##"<polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85" stroke-linejoin="round"/>"##,
798                    p(cx - hw), p(yy), p(cx), p(yy - r*0.35), p(cx + hw), p(yy)).ok();
799            }
800            write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"##,
801                p(cx), p(cy - r*1.05), p(cx), p(cy + r*0.55)).ok();
802            s
803        }
804        Cat::Torii => {
805            format!(
806                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
807                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
808                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.5" opacity="0.9"/>
809                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.5" opacity="0.9"/>"##,
810                p(cx - r*0.95), p(cy - r*0.6), p(cx + r*0.95), p(cy - r*0.6),
811                p(cx - r*0.75), p(cy - r*0.2), p(cx + r*0.75), p(cy - r*0.2),
812                p(cx - r*0.55), p(cy - r*0.6), p(cx - r*0.55), p(cy + r*0.85),
813                p(cx + r*0.55), p(cy - r*0.6), p(cx + r*0.55), p(cy + r*0.85)
814            )
815        }
816        Cat::Cog => {
817            let mut s = String::new();
818            for i in 0..8 {
819                write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.8" transform="rotate({},{},{})"/>"##,
820                    p(cx - r*0.13), p(cy - r*1.0), p(r*0.26), p(r*0.3), p(i as f32 * 45.0), p(cx), p(cy)).ok();
821            }
822            write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.55"/>
823                         <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.9"/>"##,
824                p(cx), p(cy), p(r*0.7), p(cx), p(cy), p(r*0.3)).ok();
825            s
826        }
827        Cat::Fractal => {
828            let outer = format!("{},{} {},{} {},{}",
829                p(cx), p(cy - r*0.9), p(cx + r*0.85), p(cy + r*0.6), p(cx - r*0.85), p(cy + r*0.6));
830            let inner = format!("{},{} {},{} {},{}",
831                p(cx), p(cy + r*0.6), p(cx - r*0.42), p(cy - r*0.12), p(cx + r*0.42), p(cy - r*0.12));
832            format!(
833                r##"<polygon points="{outer}" fill="{c}" opacity="0.4" stroke="{c}" stroke-width="1.0"/>
834                   <polygon points="{inner}" fill="#0b0b1a" opacity="0.9"/>"##
835            )
836        }
837        // ── Audio (extra) ─────────────────────────────────────────────────────
838        Cat::Sfx => {
839            format!(
840                r##"<polygon points="{},{} {},{} {},{} {},{} {},{} {},{}" fill="{c}" opacity="0.85"/>"##,
841                p(cx + r*0.2), p(cy - r*0.9),
842                p(cx - r*0.5), p(cy + r*0.1),
843                p(cx - r*0.05), p(cy + r*0.1),
844                p(cx - r*0.2), p(cy + r*0.9),
845                p(cx + r*0.5), p(cy - r*0.1),
846                p(cx + r*0.05), p(cy - r*0.1)
847            )
848        }
849        Cat::Spectrum => {
850            let hs = [0.5f32, 0.9, 0.35, 0.7, 0.55];
851            let mut s = String::new();
852            for (i, &hh) in hs.iter().enumerate() {
853                let xx = cx - r*0.8 + i as f32 * r*0.4;
854                let bh = r*1.6*hh;
855                write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="0.8"/>"##,
856                    p(xx - r*0.12), p(cy + r*0.8 - bh), p(r*0.24), p(bh)).ok();
857            }
858            s
859        }
860        // ── Music / MIDI ──────────────────────────────────────────────────────
861        Cat::Music => {
862            format!(
863                r##"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{c}" opacity="0.85" transform="rotate(-20,{},{})"/>
864                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
865                   <path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
866                p(cx - r*0.35), p(cy + r*0.55), p(r*0.32), p(r*0.24), p(cx - r*0.35), p(cy + r*0.55),
867                p(cx - r*0.05), p(cy + r*0.5), p(cx - r*0.05), p(cy - r*0.8),
868                p(cx - r*0.05), p(cy - r*0.8), p(cx + r*0.5), p(cy - r*0.65), p(cx + r*0.55), p(cy - r*0.2), p(cx + r*0.3), p(cy - r*0.05)
869            )
870        }
871        Cat::Note => {
872            format!(
873                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
874                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
875                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
876                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
877                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="2.0" opacity="0.85"/>"##,
878                p(cx - r*0.55), p(cy + r*0.55), p(r*0.24),
879                p(cx + r*0.55), p(cy + r*0.3), p(r*0.24),
880                p(cx - r*0.33), p(cy + r*0.55), p(cx - r*0.33), p(cy - r*0.6),
881                p(cx + r*0.77), p(cy + r*0.3), p(cx + r*0.77), p(cy - r*0.85),
882                p(cx - r*0.33), p(cy - r*0.6), p(cx + r*0.77), p(cy - r*0.85)
883            )
884        }
885        // ── Cryptography ──────────────────────────────────────────────────────
886        Cat::Hash => {
887            let mut s = String::new();
888            for i in 0..3 {
889                let rr = r * (0.35 + i as f32 * 0.22);
890                write!(s, r##"<path d="M {},{} A {},{} 0 1,1 {},{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="{:.2}"/>"##,
891                    p(cx - rr), p(cy), p(rr), p(rr), p(cx + rr), p(cy), 0.85 - i as f32*0.18).ok();
892            }
893            write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##, p(cx), p(cy), p(r*0.12)).ok();
894            s
895        }
896        Cat::Cipher => {
897            format!(
898                r##"<path d="M {},{} A {},{} 0 0,1 {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.85"/>
899                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5" stroke="{c}" stroke-width="1.1"/>
900                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.9"/>"##,
901                p(cx - r*0.45), p(cy - r*0.05), p(r*0.45), p(r*0.45), p(cx + r*0.45), p(cy - r*0.05),
902                p(cx - r*0.7), p(cy - r*0.05), p(r*1.4), p(r*0.95),
903                p(cx), p(cy + r*0.4), p(r*0.14)
904            )
905        }
906        Cat::Sign => {
907            format!(
908                r##"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>
909                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.5"/>"##,
910                p(cx - r*0.9), p(cy + r*0.4),
911                p(cx - r*0.4), p(cy - r*0.7), p(cx), p(cy + r*0.6), p(cx + r*0.3), p(cy - r*0.1),
912                p(cx + r*0.7), p(cy - r*0.7), p(cx + r*0.9), p(cy + r*0.3),
913                p(cx - r), p(cy + r*0.75), p(cx + r), p(cy + r*0.75)
914            )
915        }
916        Cat::Kem => {
917            format!(
918                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
919                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
920                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
921                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
922                p(cx - r*0.5), p(cy - r*0.4), p(r*0.4),
923                p(cx - r*0.18), p(cy - r*0.12), p(cx + r*0.8), p(cy + r*0.75),
924                p(cx + r*0.55), p(cy + r*0.5), p(cx + r*0.8), p(cy + r*0.25),
925                p(cx + r*0.8), p(cy + r*0.75), p(cx + r*1.05), p(cy + r*0.5)
926            )
927        }
928        Cat::Shard => {
929            format!(
930                r##"<polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.5"/>
931                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.3"/>
932                   <polygon points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>"##,
933                p(cx), p(cy - r), p(cx - r*0.85), p(cy + r*0.5), p(cx), p(cy + r*0.2),
934                p(cx), p(cy - r), p(cx + r*0.85), p(cy + r*0.5), p(cx), p(cy + r*0.2),
935                p(cx), p(cy - r), p(cx + r*0.85), p(cy + r*0.5), p(cx - r*0.85), p(cy + r*0.5)
936            )
937        }
938        // ── Physics ───────────────────────────────────────────────────────────
939        Cat::Rigid => {
940            format!(
941                r##"<polygon points="{},{} {},{} {},{} {},{}" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.0"/>
942                   <path d="M {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.2" stroke="{c}" stroke-width="1.0"/>
943                   <path d="M {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.28" stroke="{c}" stroke-width="1.0"/>"##,
944                p(cx), p(cy - r*0.9), p(cx + r*0.85), p(cy - r*0.4), p(cx), p(cy + r*0.1), p(cx - r*0.85), p(cy - r*0.4),
945                p(cx - r*0.85), p(cy - r*0.4), p(cx), p(cy + r*0.1), p(cx), p(cy + r*0.95), p(cx - r*0.85), p(cy + r*0.45),
946                p(cx + r*0.85), p(cy - r*0.4), p(cx), p(cy + r*0.1), p(cx), p(cy + r*0.95), p(cx + r*0.85), p(cy + r*0.45)
947            )
948        }
949        Cat::Soft => {
950            format!(
951                r##"<path d="M {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} Z" fill="{c}" opacity="0.45" stroke="{c}" stroke-width="1.0"/>"##,
952                p(cx), p(cy - r*0.85),
953                p(cx + r*0.7), p(cy - r*0.9), p(cx + r*0.95), p(cy - r*0.2), p(cx + r*0.8), p(cy + r*0.4),
954                p(cx + r*0.6), p(cy + r*0.95), p(cx - r*0.1), p(cy + r*0.9), p(cx - r*0.6), p(cy + r*0.7),
955                p(cx - r*0.95), p(cy + r*0.5), p(cx - r*0.9), p(cy - r*0.3), p(cx - r*0.7), p(cy - r*0.6),
956                p(cx - r*0.5), p(cy - r*0.85), p(cx - r*0.2), p(cy - r*0.95), p(cx), p(cy - r*0.85)
957            )
958        }
959        Cat::Liquid => {
960            format!(
961                r##"<path d="M {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} Z" fill="{c}" opacity="0.5" stroke="{c}" stroke-width="1.0"/>
962                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="#ffffff" opacity="0.25"/>"##,
963                p(cx), p(cy - r*0.9),
964                p(cx + r*0.75), p(cy - r*0.1), p(cx + r*0.6), p(cy + r*0.8), p(cx), p(cy + r*0.85),
965                p(cx - r*0.6), p(cy + r*0.8), p(cx - r*0.75), p(cy - r*0.1), p(cx), p(cy - r*0.9),
966                p(cx - r*0.25), p(cy + r*0.25), p(r*0.16), p(r*0.28)
967            )
968        }
969        Cat::Force => {
970            format!(
971                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.8" opacity="0.9" stroke-linecap="round"/>
972                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"##,
973                p(cx - r*0.7), p(cy + r*0.7), p(cx + r*0.45), p(cy - r*0.45),
974                p(cx + r*0.8), p(cy - r*0.8), p(cx + r*0.2), p(cy - r*0.7), p(cx + r*0.7), p(cy - r*0.15)
975            )
976        }
977        Cat::Collide => {
978            let mut s = String::new();
979            for i in 0..8 {
980                let a = (i as f32 * 45.0).to_radians();
981                let r1 = r * 0.3; let r2 = if i % 2 == 0 { r * 0.95 } else { r * 0.6 };
982                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
983                    p(cx + r1*a.cos()), p(cy + r1*a.sin()), p(cx + r2*a.cos()), p(cy + r2*a.sin())).ok();
984            }
985            write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##, p(cx), p(cy), p(r*0.18)).ok();
986            s
987        }
988        // ── 3D mesh & drawing ─────────────────────────────────────────────────
989        Cat::Mesh => {
990            format!(
991                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.85"/>
992                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="none" stroke="{c}" stroke-width="0.9" opacity="0.6"/>
993                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="none" stroke="{c}" stroke-width="0.9" opacity="0.6"/>
994                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.9" opacity="0.6"/>"##,
995                p(cx), p(cy), p(r*0.9),
996                p(cx), p(cy), p(r*0.38), p(r*0.9),
997                p(cx), p(cy), p(r*0.9), p(r*0.38),
998                p(cx - r*0.9), p(cy), p(cx + r*0.9), p(cy)
999            )
1000        }
1001        Cat::Draw3D => {
1002            format!(
1003                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1004                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.75"/>
1005                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.6"/>
1006                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1007                p(cx), p(cy + r*0.3), p(cx), p(cy - r*0.9),
1008                p(cx), p(cy + r*0.3), p(cx + r*0.9), p(cy + r*0.7),
1009                p(cx), p(cy + r*0.3), p(cx - r*0.85), p(cy + r*0.6),
1010                p(cx), p(cy + r*0.3), p(r*0.13)
1011            )
1012        }
1013        Cat::Draw2D => {
1014            format!(
1015                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.6" opacity="0.9" stroke-linecap="round"/>
1016                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>
1017                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1018                p(cx - r*0.8), p(cy + r*0.8), p(cx + r*0.8), p(cy - r*0.8),
1019                p(cx - r*0.8), p(cy + r*0.8), p(r*0.16),
1020                p(cx + r*0.8), p(cy - r*0.8), p(r*0.16)
1021            )
1022        }
1023        // ── Math ──────────────────────────────────────────────────────────────
1024        Cat::Trig => {
1025            format!(
1026                r##"<polygon points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1027                   <path d="M {},{} A {},{} 0 0,0 {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
1028                p(cx - r*0.8), p(cy + r*0.7), p(cx + r*0.8), p(cy + r*0.7), p(cx - r*0.8), p(cy - r*0.7),
1029                p(cx - r*0.35), p(cy + r*0.7), p(r*0.45), p(r*0.45), p(cx - r*0.8), p(cy + r*0.38)
1030            )
1031        }
1032        Cat::MathFn => {
1033            format!(
1034                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.4"/>
1035                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.4"/>
1036                   <path d="M {},{} Q {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.9"/>"##,
1037                p(cx - r*0.9), p(cy), p(cx + r*0.9), p(cy),
1038                p(cx), p(cy - r*0.9), p(cx), p(cy + r*0.9),
1039                p(cx - r*0.8), p(cy - r*0.7), p(cx), p(cy + r*1.1), p(cx + r*0.8), p(cy - r*0.7)
1040            )
1041        }
1042        Cat::Noise => {
1043            format!(
1044                r##"<polyline points="{},{} {},{} {},{} {},{} {},{} {},{} {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85" stroke-linejoin="round"/>"##,
1045                p(cx - r*0.9), p(cy + r*0.1),
1046                p(cx - r*0.65), p(cy - r*0.6),
1047                p(cx - r*0.4), p(cy + r*0.5),
1048                p(cx - r*0.15), p(cy - r*0.4),
1049                p(cx + r*0.1), p(cy + r*0.7),
1050                p(cx + r*0.35), p(cy - r*0.5),
1051                p(cx + r*0.6), p(cy + r*0.3),
1052                p(cx + r*0.8), p(cy - r*0.3),
1053                p(cx + r*0.95), p(cy + r*0.2)
1054            )
1055        }
1056        // ── Networking & AI ───────────────────────────────────────────────────
1057        Cat::Net => {
1058            let mut s = String::new();
1059            let nodes = [(0.0f32, -0.85f32), (0.8, 0.4), (-0.8, 0.4)];
1060            for &(nx, ny) in nodes.iter() {
1061                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>"##,
1062                    p(cx), p(cy), p(cx + nx*r), p(cy + ny*r)).ok();
1063            }
1064            write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##, p(cx), p(cy), p(r*0.2)).ok();
1065            for &(nx, ny) in nodes.iter() {
1066                write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##, p(cx + nx*r), p(cy + ny*r), p(r*0.18)).ok();
1067            }
1068            s
1069        }
1070        Cat::Neural => {
1071            let mut s = String::new();
1072            let l0 = [-0.6f32, 0.0, 0.6];
1073            let l1 = [-0.3f32, 0.3];
1074            let x0 = cx - r*0.7; let x1 = cx; let x2 = cx + r*0.7;
1075            for &a in l0.iter() { for &b in l1.iter() {
1076                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.6" opacity="0.4"/>"##,
1077                    p(x0), p(cy + a*r), p(x1), p(cy + b*r)).ok();
1078            }}
1079            for &b in l1.iter() {
1080                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.6" opacity="0.4"/>"##,
1081                    p(x1), p(cy + b*r), p(x2), p(cy)).ok();
1082            }
1083            for &a in l0.iter() { write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##, p(x0), p(cy + a*r), p(r*0.14)).ok(); }
1084            for &b in l1.iter() { write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##, p(x1), p(cy + b*r), p(r*0.14)).ok(); }
1085            write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##, p(x2), p(cy), p(r*0.14)).ok();
1086            s
1087        }
1088        Cat::Behavior => {
1089            format!(
1090                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
1091                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
1092                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
1093                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>
1094                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
1095                p(cx), p(cy - r*0.6), p(cx - r*0.7), p(cy + r*0.6),
1096                p(cx), p(cy - r*0.6), p(cx + r*0.7), p(cy + r*0.6),
1097                p(cx), p(cy - r*0.6), p(r*0.2),
1098                p(cx - r*0.7), p(cy + r*0.6), p(r*0.18),
1099                p(cx + r*0.7), p(cy + r*0.6), p(r*0.18)
1100            )
1101        }
1102        // ── UI / dialog / holograms ───────────────────────────────────────────
1103        Cat::Widget => {
1104            format!(
1105                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
1106                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>
1107                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"##,
1108                p(cx - r*0.85), p(cy - r*0.75), p(r*1.7), p(r*1.5),
1109                p(cx - r*0.85), p(cy - r*0.3), p(cx + r*0.85), p(cy - r*0.3),
1110                p(cx - r*0.5), p(cy + r*0.1), p(r*1.0), p(r*0.45)
1111            )
1112        }
1113        Cat::Hud => {
1114            format!(
1115                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.85"/>
1116                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.6"/>
1117                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.6"/>
1118                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.9"/>
1119                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1120                p(cx), p(cy), p(r*0.9),
1121                p(cx - r*0.9), p(cy), p(cx + r*0.9), p(cy),
1122                p(cx), p(cy - r*0.9), p(cx), p(cy + r*0.9),
1123                p(cx), p(cy), p(cx + r*0.64), p(cy - r*0.64),
1124                p(cx + r*0.4), p(cy - r*0.4), p(r*0.13)
1125            )
1126        }
1127        Cat::Dialog => {
1128            format!(
1129                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="4" fill="{c}" opacity="0.4" stroke="{c}" stroke-width="1.0"/>
1130                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.4"/>
1131                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>
1132                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>
1133                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>"##,
1134                p(cx - r*0.9), p(cy - r*0.8), p(r*1.8), p(r*1.2),
1135                p(cx - r*0.4), p(cy + r*0.4), p(cx - r*0.1), p(cy + r*0.4), p(cx - r*0.55), p(cy + r*0.9),
1136                p(cx - r*0.4), p(cy - r*0.2), p(r*0.1),
1137                p(cx), p(cy - r*0.2), p(r*0.1),
1138                p(cx + r*0.4), p(cy - r*0.2), p(r*0.1)
1139            )
1140        }
1141        Cat::Holo => {
1142            let mut s = String::new();
1143            for i in 0..3 {
1144                let off = (i as f32 - 1.0) * r*0.22;
1145                write!(s, r##"<polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.3" stroke="{c}" stroke-width="0.8" stroke-opacity="0.7"/>"##,
1146                    p(cx + off), p(cy - r*0.8), p(cx + off + r*0.8), p(cy + r*0.6), p(cx + off - r*0.8), p(cy + r*0.6)).ok();
1147            }
1148            s
1149        }
1150        // ── Text & vector ─────────────────────────────────────────────────────
1151        Cat::Str => {
1152            let mut s = String::new();
1153            for &dx in [-0.5f32, 0.3].iter() {
1154                write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
1155                             <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
1156                    p(cx + dx*r), p(cy - r*0.4), p(r*0.16),
1157                    p(cx + dx*r + r*0.3), p(cy - r*0.4), p(r*0.16)).ok();
1158            }
1159            for j in 0..2 {
1160                let yy = cy + r*0.3 + j as f32 * r*0.45;
1161                let ww = if j == 0 { r*1.6 } else { r*1.0 };
1162                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.6"/>"##,
1163                    p(cx - r*0.8), p(yy), p(cx - r*0.8 + ww), p(yy)).ok();
1164            }
1165            s
1166        }
1167        Cat::Font => {
1168            format!(
1169                r##"<polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.9" stroke-linejoin="round"/>
1170                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1171                p(cx - r*0.7), p(cy + r*0.8), p(cx), p(cy - r*0.85), p(cx + r*0.7), p(cy + r*0.8),
1172                p(cx - r*0.38), p(cy + r*0.15), p(cx + r*0.38), p(cy + r*0.15)
1173            )
1174        }
1175        Cat::Vector => {
1176            format!(
1177                r##"<path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1178                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.7" opacity="0.5"/>
1179                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.7" opacity="0.5"/>
1180                   <rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.85"/>
1181                   <rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.85"/>
1182                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
1183                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
1184                p(cx - r*0.8), p(cy + r*0.7), p(cx - r*0.3), p(cy - r*0.9), p(cx + r*0.3), p(cy - r*0.9), p(cx + r*0.8), p(cy + r*0.7),
1185                p(cx - r*0.8), p(cy + r*0.7), p(cx - r*0.3), p(cy - r*0.9),
1186                p(cx + r*0.8), p(cy + r*0.7), p(cx + r*0.3), p(cy - r*0.9),
1187                p(cx - r*0.9), p(cy + r*0.6), p(r*0.2), p(r*0.2),
1188                p(cx + r*0.7), p(cy + r*0.6), p(r*0.2), p(r*0.2),
1189                p(cx - r*0.3), p(cy - r*0.9), p(r*0.14),
1190                p(cx + r*0.3), p(cy - r*0.9), p(r*0.14)
1191            )
1192        }
1193        // ── Colour / shading / scene ──────────────────────────────────────────
1194        Cat::Color => {
1195            format!(
1196                r##"<circle cx="{}" cy="{}" r="{}" fill="#ff5e8a" opacity="0.5"/>
1197                   <circle cx="{}" cy="{}" r="{}" fill="#50fa7b" opacity="0.5"/>
1198                   <circle cx="{}" cy="{}" r="{}" fill="#6ab0f5" opacity="0.5"/>"##,
1199                p(cx), p(cy - r*0.4), p(r*0.6),
1200                p(cx - r*0.45), p(cy + r*0.35), p(r*0.6),
1201                p(cx + r*0.45), p(cy + r*0.35), p(r*0.6)
1202            )
1203        }
1204        Cat::Shade => {
1205            format!(
1206                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25" stroke="{c}" stroke-width="1.0"/>
1207                   <path d="M {},{} A {},{} 0 0,1 {},{} A {},{} 0 0,0 {},{} Z" fill="{c}" opacity="0.6"/>
1208                   <circle cx="{}" cy="{}" r="{}" fill="#ffffff" opacity="0.3"/>"##,
1209                p(cx), p(cy), p(r*0.9),
1210                p(cx), p(cy - r*0.9), p(r*0.9), p(r*0.9), p(cx), p(cy + r*0.9), p(r*0.45), p(r*0.9), p(cx), p(cy - r*0.9),
1211                p(cx + r*0.35), p(cy - r*0.35), p(r*0.18)
1212            )
1213        }
1214        Cat::Window => {
1215            format!(
1216                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1217                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
1218                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
1219                p(cx - r*0.85), p(cy - r*0.85), p(r*1.7), p(r*1.7),
1220                p(cx), p(cy - r*0.85), p(cx), p(cy + r*0.85),
1221                p(cx - r*0.85), p(cy), p(cx + r*0.85), p(cy)
1222            )
1223        }
1224        // ── Input ─────────────────────────────────────────────────────────────
1225        Cat::Key => {
1226            format!(
1227                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="3" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.2"/>
1228                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
1229                p(cx - r*0.8), p(cy - r*0.8), p(r*1.6), p(r*1.6),
1230                p(cx - r*0.45), p(cy - r*0.45), p(r*0.9), p(r*0.9)
1231            )
1232        }
1233        Cat::Mouse => {
1234            format!(
1235                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
1236                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>
1237                   <rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="0.85"/>"##,
1238                p(cx - r*0.55), p(cy - r*0.85), p(r*1.1), p(r*1.7), p(r*0.55),
1239                p(cx), p(cy - r*0.85), p(cx), p(cy - r*0.1),
1240                p(cx - r*0.08), p(cy - r*0.7), p(r*0.16), p(r*0.4)
1241            )
1242        }
1243        // ── IO / system / timing ──────────────────────────────────────────────
1244        Cat::Print => {
1245            let mut s = format!(
1246                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="1.5" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>"##,
1247                p(cx - r*0.6), p(cy - r*0.85), p(r*1.2), p(r*1.7));
1248            for j in 0..4 {
1249                let yy = cy - r*0.45 + j as f32 * r*0.35;
1250                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>"##,
1251                    p(cx - r*0.4), p(yy), p(cx + r*0.4), p(yy)).ok();
1252            }
1253            s
1254        }
1255        Cat::File => {
1256            format!(
1257                r##"<path d="M {},{} L {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.1"/>
1258                   <polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>"##,
1259                p(cx - r*0.6), p(cy - r*0.85),
1260                p(cx + r*0.25), p(cy - r*0.85),
1261                p(cx + r*0.6), p(cy - r*0.45),
1262                p(cx + r*0.6), p(cy + r*0.85),
1263                p(cx - r*0.6), p(cy + r*0.85),
1264                p(cx + r*0.25), p(cy - r*0.85), p(cx + r*0.25), p(cy - r*0.45), p(cx + r*0.6), p(cy - r*0.45)
1265            )
1266        }
1267        Cat::Sys => {
1268            format!(
1269                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.8"/>
1270                   <polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.9" stroke-linecap="round" stroke-linejoin="round"/>
1271                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.9" stroke-linecap="round"/>"##,
1272                p(cx - r*0.9), p(cy - r*0.7), p(r*1.8), p(r*1.4),
1273                p(cx - r*0.45), p(cy - r*0.2), p(cx - r*0.1), p(cy + r*0.15), p(cx - r*0.45), p(cy + r*0.5),
1274                p(cx + r*0.1), p(cy + r*0.5), p(cx + r*0.5), p(cy + r*0.5)
1275            )
1276        }
1277        Cat::Anim => {
1278            let mut s = String::new();
1279            for i in 0..6 {
1280                let a = (i as f32 * 60.0).to_radians();
1281                let rad = r*0.7;
1282                let dotr = r*(0.08 + 0.16*(i as f32/6.0));
1283                write!(s, r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="{:.2}"/>"##,
1284                    p(cx + rad*a.cos()), p(cy + rad*a.sin()), p(dotr), 0.35 + i as f32*0.1).ok();
1285            }
1286            s
1287        }
1288        Cat::Const => {
1289            format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2"
1290                           fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
1291                       <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.1" opacity="0.5"/>"#,
1292                p(cx-r*0.7), p(cy-r*0.55), p(r*1.4), p(r*1.1),
1293                p(cx-r*0.4), p(cy), p(cx+r*0.4), p(cy))
1294        }
1295    }
1296}
1297
1298// ── Card rendering ────────────────────────────────────────────────────────────
1299
1300fn card_height(card: &FuncCard) -> f32 {
1301    let icon_rows = (card.calls.len() + ICONS_ROW - 1).max(1) / ICONS_ROW + 1;
1302    let base = CARD_PAD * 2.0          // top+bottom padding
1303        + 32.0                          // name header
1304        + 20.0;                         // params + stats row
1305    base + icon_rows as f32 * (ICON_SZ + ICON_GAP) + ICON_GAP
1306}
1307
1308fn render_card(card: &FuncCard, x: f32, y: f32) -> String {
1309    let h = card_height(card);
1310    let w = CARD_W;
1311    let r = CARD_ROUNDING;
1312
1313    // Dominant colour from most-common call category (by raw count)
1314    let dominant = card.calls.iter()
1315        .max_by_key(|c| c.count)
1316        .map(|c| c.cat.color())
1317        .unwrap_or(Cat::User.color());
1318
1319    let border_color = if card.is_entry { GOLD } else { dominant };
1320    let border_w     = if card.is_entry { 2.5  } else { 1.2 };
1321    let glow_filter  = if card.is_entry { r#" filter="url(#glow-gold)""# } else { "" };
1322
1323    let mut s = String::new();
1324
1325    // Card background
1326    write!(s, r#"<rect x="{}" y="{}" width="{w}" height="{h}" rx="{r}"
1327                      fill="{CARD_BG}" stroke="{border_color}" stroke-width="{border_w}"{glow_filter}/>
1328                 <line x1="{}" y1="{}" x2="{}" y2="{}"
1329                      stroke="{border_color}" stroke-width="3" opacity="0.6"/>
1330"#,
1331        p(x), p(y),
1332        p(x+r), p(y+h), p(x+r), p(y)   // left accent stripe
1333    ).ok();
1334
1335    // Function name
1336    let name_y = y + CARD_PAD + 18.0;
1337    let entry_badge = if card.is_entry {
1338        format!(r#" <text x="{}" y="{}" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.8">⬡ ENTRY</text>"#,
1339            p(x + w - CARD_PAD - 48.0), p(name_y))
1340    } else { String::new() };
1341
1342    write!(s, r#"<text x="{}" y="{}" fill="{}" font-size="13" font-weight="bold">{}</text>{}"#,
1343        p(x + CARD_PAD + 10.0), p(name_y),
1344        if card.is_entry { GOLD } else { TEXT },
1345        xe(&card.name),
1346        entry_badge
1347    ).ok();
1348
1349    // Params + stats row
1350    let stats_y = name_y + 18.0;
1351    let params_str = if card.params.is_empty() { String::new() }
1352        else { format!("({})", card.params.join(", ")) };
1353    let stats_str = {
1354        let mut parts = Vec::new();
1355        if card.vtex_count > 0  { parts.push(format!("{} vtex",  card.vtex_count)); }
1356        if card.audio_count > 0 { parts.push(format!("{} audio", card.audio_count)); }
1357        if card.has_loop        { parts.push("↺ loop".into()); }
1358        parts.join("  ·  ")
1359    };
1360    write!(s, r#"<text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{}</text>
1361                 <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10" text-anchor="end">{}</text>
1362"#,
1363        p(x + CARD_PAD + 10.0), p(stats_y), xe(&params_str),
1364        p(x + w - CARD_PAD), p(stats_y), xe(&stats_str)
1365    ).ok();
1366
1367    // Divider
1368    let div_y = stats_y + 6.0;
1369    write!(s, r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{CARD_BD}" stroke-width="1"/>"#,
1370        p(x + CARD_PAD), p(div_y), p(x + w - CARD_PAD), p(div_y)).ok();
1371
1372    // Icons
1373    let icon_y0 = div_y + ICON_GAP + ICON_SZ / 2.0;
1374    let icon_x0 = x + CARD_PAD + ICON_SZ / 2.0 + 6.0;
1375    let ir = ICON_SZ / 2.0 * 0.82; // icon radius slightly smaller than half-cell
1376
1377    for (i, call) in card.calls.iter().enumerate() {
1378        let row = i / ICONS_ROW;
1379        let col = i % ICONS_ROW;
1380        let ix = icon_x0 + col as f32 * (ICON_SZ + ICON_GAP);
1381        let iy = icon_y0 + row as f32 * (ICON_SZ + ICON_GAP);
1382
1383        // Icon cell background
1384        write!(s, r#"<rect x="{}" y="{}" width="{ICON_SZ}" height="{ICON_SZ}" rx="3"
1385                          fill="{}" opacity="0.12"/>
1386"#,
1387            p(ix - ICON_SZ/2.0), p(iy - ICON_SZ/2.0),
1388            call.cat.color()
1389        ).ok();
1390
1391        // Icon shape
1392        s.push_str(&icon(call.cat, ix, iy, ir));
1393
1394        // Count badge (if > 1)
1395        if call.count > 1 {
1396            write!(s, r##"<rect x="{}" y="{}" width="13" height="10" rx="3" fill="{}" opacity="0.9"/>
1397                         <text x="{}" y="{}" fill="#0a0a1a" font-size="8" font-weight="bold" text-anchor="middle">{}</text>"##,
1398                p(ix + ir - 2.0), p(iy - ir - 1.0), call.cat.color(),
1399                p(ix + ir + 4.5), p(iy - ir + 7.0), call.count
1400            ).ok();
1401        }
1402    }
1403
1404    s
1405}
1406
1407// ── Legend, header, sidebar ───────────────────────────────────────────────────
1408
1409fn render_header(filename: &str, funcs: &[FuncCard]) -> String {
1410    let name = std::path::Path::new(filename)
1411        .file_name().map(|n| n.to_string_lossy().into_owned())
1412        .unwrap_or_else(|| filename.to_string());
1413    let n_fns   = funcs.len();
1414    let count = |pred: fn(Cat) -> bool| -> usize {
1415        funcs.iter().flat_map(|f| &f.calls).filter(|c| pred(c.cat)).map(|c| c.count).sum()
1416    };
1417    let n_vtex    = count(is_vtex);
1418    let n_audio   = count(is_audio);
1419    let n_crypto  = count(is_crypto);
1420    let n_physics = count(is_physics);
1421    let n_ai      = count(is_ai);
1422    let n_calls: usize = funcs.iter().map(|f| f.calls.len()).sum();
1423
1424    // Domain stat chips — only show those actually present, so the header stays tight.
1425    let mut stats = vec![format!("{n_fns} fn"), format!("{n_calls} call types")];
1426    for (n, lbl) in [(n_vtex, "vtex"), (n_audio, "audio"), (n_crypto, "crypto"),
1427                     (n_physics, "physics"), (n_ai, "ai")] {
1428        if n > 0 { stats.push(format!("{n} {lbl}")); }
1429    }
1430
1431    format!(
1432        r##"<rect x="0" y="0" width="{SVG_W}" height="{HEADER_H}" fill="#080814"/>
1433           <line x1="0" y1="{HEADER_H}" x2="{SVG_W}" y2="{HEADER_H}" stroke="#1a1a3a" stroke-width="1"/>
1434           <text x="20" y="22" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.6" letter-spacing="2">LING VISUALIZER</text>
1435           <text x="20" y="56" fill="{TEXT}" font-size="26" font-weight="bold">{}</text>
1436           <text x="{}" y="56" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"##,
1437        xe(&name),
1438        SVG_W - 20.0,
1439        stats.join("  ·  ")
1440    )
1441}
1442
1443fn render_legend(funcs: &[FuncCard]) -> String {
1444    // Collect present categories, then order them by coarse domain so related
1445    // icons cluster together; a faint tag introduces each domain group.
1446    let mut present: Vec<Cat> = Vec::new();
1447    let mut seen = HashSet::new();
1448    for f in funcs {
1449        for c in &f.calls {
1450            if seen.insert(c.cat) { present.push(c.cat); }
1451        }
1452    }
1453    present.sort_by(|a, b| a.domain().cmp(b.domain()).then(a.label().cmp(b.label())));
1454
1455    let mut s = format!(
1456        r##"<rect x="0" y="{HEADER_H}" width="{SVG_W}" height="{LEGEND_H}" fill="#0a0a18"/>
1457           <line x1="0" y1="{}" x2="{SVG_W}" y2="{}" stroke="#171730" stroke-width="1"/>"##,
1458        HEADER_H + LEGEND_H, HEADER_H + LEGEND_H
1459    );
1460
1461    let row_h = 21.0;
1462    let x_start = GRID_X + 8.0;
1463    let x_max = SVG_W - 24.0;
1464    let mut lx = x_start;
1465    let mut row = 0usize;
1466    let mut cur_domain = "";
1467    let row_y = |r: usize| HEADER_H + 17.0 + r as f32 * row_h;
1468
1469    for cat in present {
1470        let c   = cat.color();
1471        let lbl = cat.label();
1472        let dom = cat.domain();
1473
1474        // Domain tag whenever the group changes (and a little extra lead space).
1475        if dom != cur_domain {
1476            let tag_w = dom.len() as f32 * 6.0 + 12.0;
1477            if lx > x_start && lx + tag_w > x_max { row += 1; lx = x_start; }
1478            if row > 1 { break; } // at most two rows in the band
1479            let ly = row_y(row);
1480            s.push_str(&format!(
1481                r##"<text x="{}" y="{}" fill="#3f3f66" font-size="8" font-weight="bold" letter-spacing="1">{}</text>"##,
1482                p(lx), p(ly + 3.0), dom.to_uppercase()
1483            ));
1484            lx += tag_w;
1485            cur_domain = dom;
1486        }
1487
1488        let item_w = 20.0 + lbl.len() as f32 * 6.2 + 12.0;
1489        if lx + item_w > x_max { row += 1; lx = x_start; }
1490        if row > 1 { break; }
1491        let ly = row_y(row);
1492        let icon_svg = icon(cat, lx + 7.0, ly, 6.0);
1493        s.push_str(&format!(
1494            r#"<rect x="{}" y="{}" width="14" height="14" rx="3" fill="{c}" opacity="0.12"/>
1495               {}
1496               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{lbl}</text>"#,
1497            p(lx), p(ly - 7.0),
1498            icon_svg,
1499            p(lx + 18.0), p(ly + 4.0)
1500        ));
1501        lx += item_w;
1502    }
1503    s
1504}
1505
1506fn render_sidebar(globals: &[GlobalConst], total_h: f32) -> String {
1507    let h = total_h - CONTENT_Y;
1508    let mut s = format!(
1509        r##"<rect x="0" y="{CONTENT_Y}" width="{SIDEBAR_W}" height="{h}" fill="{SIDEBAR_BG}"/>
1510           <line x1="{SIDEBAR_W}" y1="{CONTENT_Y}" x2="{SIDEBAR_W}" y2="{total_h}" stroke="#1a1a3a" stroke-width="1"/>
1511           <text x="14" y="{}" fill="{TEXT_DIM}" font-size="9" font-weight="bold" letter-spacing="2">CONSTANTS</text>"##,
1512        CONTENT_Y + 20.0
1513    );
1514
1515    let mut gy = CONTENT_Y + 38.0;
1516    for g in globals {
1517        // Small colored diamond
1518        write!(s,
1519            r#"<rect x="{}" y="{}" width="8" height="8" rx="1" fill="{}" opacity="0.7"
1520                    transform="rotate(45,{},{})"/>
1521               <text x="{}" y="{}" fill="{}" font-size="12" font-weight="bold">{}</text>
1522               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"#,
1523            p(14.0), p(gy - 7.0), Cat::Star.color(),
1524            p(18.0), p(gy - 3.0),
1525            p(28.0), p(gy), Cat::Grid.color(), xe(&g.name),
1526            p(SIDEBAR_W - 10.0), p(gy), xe(&g.value)
1527        ).ok();
1528        gy += 22.0;
1529    }
1530    s
1531}
1532
1533// ── SVG defs (filters, patterns) ─────────────────────────────────────────────
1534
1535const DEFS: &str = r##"<defs>
1536  <filter id="glow-gold" x="-30%" y="-30%" width="160%" height="160%">
1537    <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
1538    <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
1539  </filter>
1540  <pattern id="grid-pat" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
1541    <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#ffffff" stroke-width="0.4"/>
1542  </pattern>
1543</defs>"##;
1544
1545// ── Public entry point ────────────────────────────────────────────────────────
1546
1547pub fn render(filename: &str, program: &Program) -> String {
1548    let doc = Document::build(filename, program);
1549
1550    // Layout: pack cards into COLS columns using shortest-column strategy
1551    let heights: Vec<f32> = doc.funcs.iter().map(|f| card_height(f)).collect();
1552    let mut col_y = vec![CONTENT_Y; COLS];
1553    let mut positions: Vec<(f32, f32)> = Vec::with_capacity(doc.funcs.len());
1554
1555    for &h in &heights {
1556        let (col, &cy) = col_y.iter().enumerate()
1557            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap();
1558        let cx = GRID_X + col as f32 * (CARD_W + CARD_GAP);
1559        positions.push((cx, cy));
1560        col_y[col] += h + CARD_GAP;
1561    }
1562
1563    let total_h = col_y.iter().cloned().fold(0.0f32, f32::max) + 40.0;
1564
1565    let mut svg = String::new();
1566    write!(svg,
1567        r#"<?xml version="1.0" encoding="UTF-8"?>
1568<svg xmlns="http://www.w3.org/2000/svg" width="{SVG_W}" height="{h}" viewBox="0 0 {SVG_W} {h}"
1569     style="font-family:'JetBrains Mono','Fira Code',monospace,sans-serif;background:{BG}">"#,
1570        h = total_h
1571    ).ok();
1572
1573    svg.push_str(DEFS);
1574
1575    // Background
1576    write!(svg, r#"<rect width="{SVG_W}" height="{total_h}" fill="{BG}"/>
1577                   <rect width="{SVG_W}" height="{total_h}" fill="url(#grid-pat)" opacity="0.05"/>"#,
1578        total_h = total_h).ok();
1579
1580    svg.push_str(&render_header(&doc.filename, &doc.funcs));
1581    svg.push_str(&render_legend(&doc.funcs));
1582    svg.push_str(&render_sidebar(&doc.globals, total_h));
1583
1584    for (card, &(cx, cy)) in doc.funcs.iter().zip(positions.iter()) {
1585        svg.push_str(&render_card(card, cx, cy));
1586    }
1587
1588    svg.push_str("\n</svg>");
1589    svg
1590}