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,
47    Spiral,
48    Star,
49    Flower,
50    Lotus,
51    Chakra,
52    Yantra,
53    Hyper,
54    Tess,
55    Rain,
56    Grid,
57    Halftone,
58    Pagoda,
59    Torii,
60    Cog,
61    // ── Procedural / fractal textures (tex_*) ──
62    Fractal,
63    // ── Audio ──
64    Tone,
65    Vol,
66    Listen,
67    Sfx,
68    Spectrum,
69    // ── Music / MIDI ──
70    Music,
71    Note,
72    // ── Cryptography ──
73    Hash,
74    Cipher,
75    Sign,
76    Kem,
77    Shard,
78    // ── Physics ──
79    Rigid,
80    Soft,
81    Liquid,
82    Force,
83    Collide,
84    // ── 3D mesh & geometry ──
85    Mesh,
86    Draw3D,
87    Draw2D,
88    // ── Math ──
89    Trig,
90    MathFn,
91    Noise,
92    // ── Networking & AI ──
93    Net,
94    Neural,
95    Behavior,
96    // ── UI / dialog / holograms ──
97    Widget,
98    Hud,
99    Dialog,
100    Holo,
101    // ── Text & vector ──
102    Str,
103    Font,
104    Vector,
105    // ── Colour, shading, scene ──
106    Color,
107    Shade,
108    Camera,
109    Light,
110    Fill,
111    Present,
112    Window,
113    // ── Input ──
114    Key,
115    Mouse,
116    // ── IO / system / timing ──
117    Print,
118    File,
119    Sys,
120    Anim,
121    // ── Generic ──
122    User,
123    Loop,
124    Const,
125}
126
127impl Cat {
128    pub(crate) fn color(self) -> &'static str {
129        match self {
130            // Surface textures — neon spectrum
131            Cat::Rings => "#00e5ff",
132            Cat::Spiral => "#00ffb3",
133            Cat::Star => "#ffd700",
134            Cat::Flower => "#ff79c6",
135            Cat::Lotus => "#ff5e8a",
136            Cat::Chakra => "#bd93f9",
137            Cat::Yantra => "#ffb86c",
138            Cat::Hyper => "#5575c8",
139            Cat::Tess => "#50fa7b",
140            Cat::Rain => "#f1fa8c",
141            Cat::Grid => "#8be9fd",
142            Cat::Halftone => "#888899",
143            Cat::Pagoda => "#ff9a3c",
144            Cat::Torii => "#ff5e5e",
145            Cat::Cog => "#b0b0c8",
146            // Procedural / fractal — violet
147            Cat::Fractal => "#c77dff",
148            // Audio — magenta family
149            Cat::Tone => "#ff1493",
150            Cat::Vol => "#ff69b4",
151            Cat::Listen => "#db77d9",
152            Cat::Sfx => "#ff85c0",
153            Cat::Spectrum => "#ff4fa3",
154            // Music — purple
155            Cat::Music => "#b388ff",
156            Cat::Note => "#d8a7ff",
157            // Crypto — jade/teal family
158            Cat::Hash => "#0fd9b0",
159            Cat::Cipher => "#12b48c",
160            Cat::Sign => "#5ce0c0",
161            Cat::Kem => "#2aa98a",
162            Cat::Shard => "#7ff0d8",
163            // Physics — warm / amber + water
164            Cat::Rigid => "#ff8c42",
165            Cat::Soft => "#ffb37b",
166            Cat::Liquid => "#4aa3ff",
167            Cat::Force => "#ff6b35",
168            Cat::Collide => "#ff4d4d",
169            // 3D / draw — blue
170            Cat::Mesh => "#6aa9ff",
171            Cat::Draw3D => "#8fb8ff",
172            Cat::Draw2D => "#5fd0ff",
173            // Math — lime
174            Cat::Trig => "#b6e36b",
175            Cat::MathFn => "#d4e157",
176            Cat::Noise => "#9ccc65",
177            // Net / AI
178            Cat::Net => "#18b6e0",
179            Cat::Neural => "#ffd54f",
180            Cat::Behavior => "#ffca6b",
181            // UI / dialog / holo
182            Cat::Widget => "#7a8fb0",
183            Cat::Hud => "#92a8c8",
184            Cat::Dialog => "#e0b97d",
185            Cat::Holo => "#66ffe0",
186            // Text & vector
187            Cat::Str => "#a0b4d0",
188            Cat::Font => "#c0c8e0",
189            Cat::Vector => "#88d8c0",
190            // Colour / shading / scene
191            Cat::Color => "#ff7eb6",
192            Cat::Shade => "#9d8df1",
193            Cat::Camera => "#ffe066",
194            Cat::Light => "#ffe4b5",
195            Cat::Fill => "#3a3a6a",
196            Cat::Present => "#555580",
197            Cat::Window => "#7fd4ff",
198            // Input
199            Cat::Key => "#ff9e7d",
200            Cat::Mouse => "#ffbfa3",
201            // IO / system / timing
202            Cat::Print => "#9aa0b5",
203            Cat::File => "#7d8aa8",
204            Cat::Sys => "#6b7488",
205            Cat::Anim => "#ffd28a",
206            // Generic
207            Cat::User => "#6ab0f5",
208            Cat::Loop => "#ff7f50",
209            Cat::Const => "#50fa7b",
210        }
211    }
212
213    pub(crate) fn label(self) -> &'static str {
214        match self {
215            Cat::Rings => "rings",
216            Cat::Spiral => "spiral",
217            Cat::Star => "star",
218            Cat::Flower => "flower",
219            Cat::Lotus => "lotus",
220            Cat::Chakra => "chakra",
221            Cat::Yantra => "yantra",
222            Cat::Hyper => "hyperbolic",
223            Cat::Tess => "tessellated",
224            Cat::Rain => "letter rain",
225            Cat::Grid => "grid",
226            Cat::Halftone => "halftone",
227            Cat::Pagoda => "pagoda",
228            Cat::Torii => "torii",
229            Cat::Cog => "spiked cog",
230            Cat::Fractal => "fractal tex",
231            Cat::Tone => "audio tone",
232            Cat::Vol => "audio vol",
233            Cat::Listen => "listener",
234            Cat::Sfx => "sfx",
235            Cat::Spectrum => "spectrum",
236            Cat::Music => "music",
237            Cat::Note => "note",
238            Cat::Hash => "hash",
239            Cat::Cipher => "cipher",
240            Cat::Sign => "signature",
241            Cat::Kem => "key exch",
242            Cat::Shard => "secret share",
243            Cat::Rigid => "rigid body",
244            Cat::Soft => "soft body",
245            Cat::Liquid => "liquid",
246            Cat::Force => "force",
247            Cat::Collide => "collision",
248            Cat::Mesh => "mesh",
249            Cat::Draw3D => "draw 3d",
250            Cat::Draw2D => "draw 2d",
251            Cat::Trig => "trig",
252            Cat::MathFn => "math",
253            Cat::Noise => "noise",
254            Cat::Net => "network",
255            Cat::Neural => "neural net",
256            Cat::Behavior => "behavior tree",
257            Cat::Widget => "ui widget",
258            Cat::Hud => "hud",
259            Cat::Dialog => "dialog",
260            Cat::Holo => "hologram",
261            Cat::Str => "string",
262            Cat::Font => "font",
263            Cat::Vector => "svg vector",
264            Cat::Color => "color",
265            Cat::Shade => "shading",
266            Cat::Camera => "camera",
267            Cat::Light => "light",
268            Cat::Fill => "fill",
269            Cat::Present => "render",
270            Cat::Window => "window",
271            Cat::Key => "keyboard",
272            Cat::Mouse => "mouse",
273            Cat::Print => "print",
274            Cat::File => "file io",
275            Cat::Sys => "system",
276            Cat::Anim => "animation",
277            Cat::User => "fn call",
278            Cat::Loop => "loop",
279            Cat::Const => "const",
280        }
281    }
282
283    /// Coarse domain a category belongs to — used for grouped legends & stats.
284    pub(crate) fn domain(self) -> &'static str {
285        if is_vtex(self) {
286            return "texture";
287        }
288        if is_audio(self) {
289            return "audio";
290        }
291        if is_crypto(self) {
292            return "crypto";
293        }
294        if is_physics(self) {
295            return "physics";
296        }
297        if is_ai(self) {
298            return "ai";
299        }
300        match self {
301            Cat::Music | Cat::Note => "music",
302            Cat::Mesh | Cat::Draw3D | Cat::Draw2D | Cat::Vector => "geometry",
303            Cat::Trig | Cat::MathFn | Cat::Noise | Cat::Fractal => "math",
304            Cat::Widget | Cat::Hud | Cat::Dialog | Cat::Holo => "ui",
305            Cat::Str | Cat::Font => "text",
306            Cat::Color
307            | Cat::Shade
308            | Cat::Camera
309            | Cat::Light
310            | Cat::Fill
311            | Cat::Present
312            | Cat::Window => "scene",
313            Cat::Key | Cat::Mouse => "input",
314            Cat::Print | Cat::File | Cat::Sys | Cat::Anim | Cat::Net => "system",
315            _ => "code",
316        }
317    }
318}
319
320pub(crate) fn categorize(name: &str) -> Cat {
321    // ── Surface textures: vtex_* (+ Thai ลาย* / CJK aliases) ──
322    if name.starts_with("vtex_rings")
323        || name == "ลายวงซ้อน"
324        || name == "纹环"
325        || name == "輪模様"
326        || name == "輪무늬"
327    {
328        return Cat::Rings;
329    }
330    if name.starts_with("vtex_spiral")
331        || name == "ลายก้นหอย"
332        || name == "ลายเกลียว"
333        || name == "ลายเกลียวหมุน"
334        || name == "纹螺"
335        || name == "螺旋模様"
336        || name == "나선무늬"
337    {
338        return Cat::Spiral;
339    }
340    if name.starts_with("vtex_star")
341        || name == "ลายดาว"
342        || name == "纹星"
343        || name == "星模様"
344        || name == "별무늬"
345    {
346        return Cat::Star;
347    }
348    if name.starts_with("vtex_flower")
349        || name == "ลายดอกไม้"
350        || name == "ลายดอก"
351        || name == "纹花"
352        || name == "花模様"
353        || name == "꽃무늬"
354    {
355        return Cat::Flower;
356    }
357    if name.starts_with("vtex_lotus")
358        || name == "ลายบัว"
359        || name == "ลายดอกบัว"
360        || name == "纹莲"
361        || name == "蓮模様"
362        || name == "연꽃무늬"
363    {
364        return Cat::Lotus;
365    }
366    if name.starts_with("vtex_chakra")
367        || name == "ลายจักร"
368        || name == "纹轮"
369        || name == "輪模様"
370        || name == "바퀴무늬"
371    {
372        return Cat::Chakra;
373    }
374    if name.starts_with("vtex_yantra")
375        || name == "ลายยันต์"
376        || name == "纹咒"
377        || name == "護符模様"
378        || name == "부적무늬"
379    {
380        return Cat::Yantra;
381    }
382    if name.starts_with("vtex_hyper")
383        || name == "ลายไฮเพอร์โบลิก"
384        || name == "ลายไฮเปอร์"
385        || name == "纹超"
386        || name == "超次元模様"
387        || name == "초차원무늬"
388    {
389        return Cat::Hyper;
390    }
391    if name.starts_with("vtex_tessellated")
392        || name == "ลายตาข่าย"
393        || name == "纹镶嵌"
394        || name == "網目模様"
395    {
396        return Cat::Tess;
397    }
398    if name.starts_with("vtex_letter_rain")
399        || name.starts_with("vtex_rain")
400        || name == "ลายอักษรไหล"
401        || name == "ลายฝน"
402        || name == "纹雨"
403        || name == "文字雨"
404        || name == "비무늬"
405    {
406        return Cat::Rain;
407    }
408    if name.starts_with("vtex_grid")
409        || name == "ลายตาราง"
410        || name == "纹格"
411        || name == "格子模様"
412        || name == "격자무늬"
413    {
414        return Cat::Grid;
415    }
416    if name.starts_with("vtex_halftone")
417        || name == "ลายจุด"
418        || name == "ลายฮาล์ฟโทน"
419        || name == "纹半调"
420        || name == "網点模様"
421        || name == "망점"
422    {
423        return Cat::Halftone;
424    }
425    if name.starts_with("vtex_pagoda")
426        || name == "ลายเจดีย์"
427        || name == "เจดีย์"
428        || name == "纹塔"
429        || name == "탑"
430    {
431        return Cat::Pagoda;
432    }
433    if name.starts_with("vtex_torii")
434        || name == "ประตูโทริอิ"
435        || name == "纹鸟居"
436        || name == "鳥居"
437        || name == "도리이"
438    {
439        return Cat::Torii;
440    }
441    if name.starts_with("vtex_spiked_cog")
442        || name == "ฟันเฟืองหนาม"
443        || name == "纹棘轮"
444        || name == "歯車模様"
445        || name == "톱니바퀴"
446    {
447        return Cat::Cog;
448    }
449    if name.starts_with("vtex_") {
450        return Cat::Tess;
451    }
452
453    // ── Procedural / fractal textures: tex_* ──
454    if name.starts_with("tex_") {
455        return Cat::Fractal;
456    }
457
458    // ── Audio ──
459    if name == "audio_tone"
460        || name == "เสียงโทน"
461        || name == "音調"
462        || name == "音调"
463        || name == "음조"
464    {
465        return Cat::Tone;
466    }
467    if name == "audio_volume"
468        || name == "audio_bgm_volume"
469        || name == "ระดับเสียง"
470        || name == "音量"
471        || name == "음량"
472    {
473        return Cat::Vol;
474    }
475    if name == "audio_listener"
476        || name == "音声リスナー"
477        || name == "오디오리스너"
478        || name == "音频监听"
479    {
480        return Cat::Listen;
481    }
482    if name.starts_with("audio_") {
483        return Cat::Sfx;
484    }
485    if name.starts_with("mic_") || name.starts_with("fft_") {
486        return Cat::Spectrum;
487    }
488
489    // ── Music / MIDI ──
490    if name == "music_note_on"
491        || name == "music_note_off"
492        || name == "music_note"
493        || name == "music_note_name"
494    {
495        return Cat::Note;
496    }
497    if name.starts_with("music_") || name.starts_with("midi_") || name.starts_with("MIDI") {
498        return Cat::Music;
499    }
500
501    // ── Cryptography ──
502    if name == "crypto_hash"
503        || name == "sha3_512"
504        || name == "blake3"
505        || name == "hash_int"
506        || name == "hash_str"
507    {
508        return Cat::Hash;
509    }
510    if name == "crypto_seal" || name == "crypto_open" || name == "encrypt" || name == "aes_gcm_256"
511    {
512        return Cat::Cipher;
513    }
514    if name == "ed25519"
515        || name == "schnorr_verify"
516        || name == "vrf_verify"
517        || name == "derive"
518        || name == "argon2id"
519    {
520        return Cat::Sign;
521    }
522    if name == "mlkem768" || name.starts_with("hybrid_") || name.starts_with("knot_") {
523        return Cat::Kem;
524    }
525    if name == "shamir_reconstruct" {
526        return Cat::Shard;
527    }
528
529    // ── Physics ──
530    if name.starts_with("rb_") || name == "rigidbody" {
531        return Cat::Rigid;
532    }
533    if name.starts_with("soft_") {
534        return Cat::Soft;
535    }
536    if name.starts_with("liquid_") {
537        return Cat::Liquid;
538    }
539    if matches!(
540        name,
541        "force"
542            | "torque"
543            | "spring"
544            | "friction"
545            | "elasticity"
546            | "acceleration"
547            | "apply_impulse"
548            | "gyro"
549    ) {
550        return Cat::Force;
551    }
552    if matches!(
553        name,
554        "collision" | "raycast" | "aabb" | "AABB" | "constraint"
555    ) {
556        return Cat::Collide;
557    }
558
559    // ── 3D mesh & primitives ──
560    if matches!(
561        name,
562        "cube"
563            | "box"
564            | "capsule"
565            | "capsule_chain"
566            | "cylinder"
567            | "pyramid"
568            | "icosphere"
569            | "icosahedron"
570            | "octahedron"
571            | "orb_shell"
572            | "stairs"
573            | "frustum"
574    ) {
575        return Cat::Mesh;
576    }
577    if matches!(
578        name,
579        "draw_line_3d"
580            | "draw_triangle_3d"
581            | "line3d"
582            | "triangle3d"
583            | "project_3d"
584            | "render_3d"
585            | "flush_3d"
586            | "font_text_3d"
587            | "set_vertex_normals"
588    ) {
589        return Cat::Draw3D;
590    }
591
592    // ── Neural / behavior / dialog (AI) ──
593    if name.starts_with("nn_") {
594        return Cat::Neural;
595    }
596    if name.starts_with("bt_") {
597        return Cat::Behavior;
598    }
599    if name.starts_with("dialog_") {
600        return Cat::Dialog;
601    }
602
603    // ── Networking ──
604    if name.starts_with("net_") {
605        return Cat::Net;
606    }
607
608    // ── UI widgets / HUD ──
609    if matches!(
610        name,
611        "ui_radar"
612            | "ui_radar3d"
613            | "ui_minimap"
614            | "ui_compass"
615            | "ui_healthbar"
616            | "ui_reticle"
617            | "ui_target"
618            | "ui_gauge"
619            | "ui_gauge3d"
620            | "ui_vu"
621            | "ui_battery"
622            | "ui_segbar"
623            | "ui_bar"
624            | "ui_progress"
625            | "ui_cooldown"
626            | "ui_scanlines"
627            | "ui_vignette"
628            | "ui_spark"
629            | "ui_ring"
630            | "ui_counter"
631    ) {
632        return Cat::Hud;
633    }
634    if name.starts_with("ui_") {
635        return Cat::Widget;
636    }
637
638    // ── Holograms / particles ──
639    if matches!(
640        name,
641        "holo_points" | "holo_fragment_count" | "knot_points" | "sparkle" | "neon" | "psychedelic"
642    ) {
643        return Cat::Holo;
644    }
645
646    // ── Fonts & vector text ──
647    if name.starts_with("font_") {
648        return Cat::Font;
649    }
650    if name.starts_with("svg_") {
651        return Cat::Vector;
652    }
653
654    // ── Strings ──
655    if name.starts_with("str_")
656        || matches!(
657            name,
658            "split"
659                | "join"
660                | "trim"
661                | "substr"
662                | "format"
663                | "to_str"
664                | "num_str"
665                | "starts_with"
666                | "ends_with"
667        )
668    {
669        return Cat::Str;
670    }
671
672    // ── Math: trig / noise / general ──
673    if matches!(
674        name,
675        "sin"
676            | "cos"
677            | "tan"
678            | "asin"
679            | "acos"
680            | "atan"
681            | "atan2"
682            | "arcsin"
683            | "arccos"
684            | "arctan"
685            | "arctan2"
686            | "tanh"
687            | "tanhf"
688            | "hypot"
689    ) {
690        return Cat::Trig;
691    }
692    if matches!(
693        name,
694        "perlin" | "perlin3" | "noise2" | "fbm" | "vnoise" | "smoothstep"
695    ) {
696        return Cat::Noise;
697    }
698    if matches!(
699        name,
700        "sqrt"
701            | "cbrt"
702            | "pow"
703            | "exp"
704            | "ln"
705            | "log"
706            | "log2"
707            | "log10"
708            | "abs"
709            | "floor"
710            | "ceil"
711            | "round"
712            | "trunc"
713            | "fract"
714            | "sign"
715            | "clamp"
716            | "lerp"
717            | "min"
718            | "max"
719            | "rand"
720            | "pi"
721            | "tau"
722    ) {
723        return Cat::MathFn;
724    }
725
726    // ── Colour & shading ──
727    if matches!(
728        name,
729        "hsl_color"
730            | "hsv_to_rgb"
731            | "set_color"
732            | "set_color_hsl"
733            | "color"
734            | "lerp_color"
735            | "gfx_color"
736    ) {
737        return Cat::Color;
738    }
739    if matches!(
740        name,
741        "set_shade_mode"
742            | "set_rim"
743            | "set_fog"
744            | "set_ambient"
745            | "set_cel_bands"
746            | "set_blend"
747            | "set_shadow_color"
748            | "set_projection"
749            | "set_zdist"
750    ) {
751        return Cat::Shade;
752    }
753
754    // ── Camera / lights ──
755    if matches!(name, "set_camera" | "set_camera_pos" | "move_camera") {
756        return Cat::Camera;
757    }
758    if matches!(name, "add_light" | "clear_lights" | "keep_lights") {
759        return Cat::Light;
760    }
761
762    // ── Window / present / fill ──
763    if matches!(
764        name,
765        "open_window"
766            | "open_fullscreen"
767            | "gfx_window"
768            | "fullscreen"
769            | "windowed"
770            | "wait_window"
771            | "is_open"
772            | "window_is_open"
773            | "gfx_is_open"
774            | "gfx_wait"
775            | "resolution"
776            | "get_width"
777            | "get_height"
778    ) {
779        return Cat::Window;
780    }
781    if matches!(
782        name,
783        "present" | "แสดงผล" | "gfx_present" | "render" | "render_3d" | "flush_3d"
784    ) {
785        return Cat::Present;
786    }
787    if matches!(name, "fill" | "เติม" | "gfx_fill" | "clear") {
788        return Cat::Fill;
789    }
790
791    // ── 2D drawing ──
792    if name.starts_with("draw_")
793        || matches!(
794            name,
795            "gfx_line" | "gfx_pixel" | "gfx_triangle" | "line" | "triangle" | "pixel"
796        )
797    {
798        return Cat::Draw2D;
799    }
800
801    // ── Input ──
802    if name.starts_with("mouse_") || matches!(name, "capture_mouse" | "release_mouse") {
803        return Cat::Mouse;
804    }
805    if name.starts_with("key_") || matches!(name, "keys" | "text_poll" | "text_get") {
806        return Cat::Key;
807    }
808
809    // ── IO / system ──
810    if matches!(
811        name,
812        "print" | "println" | "print_file" | "imprimir" | "afficher" | "вывести" | "พิมพ์" | "打印"
813    ) {
814        return Cat::Print;
815    }
816    if matches!(name, "read_file" | "write_file" | "อ่านไฟล์" | "เขียนไฟล์")
817    {
818        return Cat::File;
819    }
820    if matches!(
821        name,
822        "system" | "get_args" | "time_now" | "sleep" | "sleep_ms"
823    ) {
824        return Cat::Sys;
825    }
826
827    // ── Animation / timing (Anima organic drivers + classic timing) ──
828    if matches!(
829        name,
830        "animation"
831            | "ease"
832            | "tick"
833            | "delta_time"
834            | "frame_count"
835            | "frame"
836            | "record_frame"
837            | "record_count"
838            | "tween"
839            | "tween_ease"
840            | "breathe"
841            | "wobble"
842            | "gait_phase"
843            | "gait_swing"
844            | "gait_lift"
845            | "spring_to"
846            | "ik2"
847    ) {
848        return Cat::Anim;
849    }
850    // ── Anima mechanical drivers — reuse the gear (Cog) glyph ──
851    if matches!(
852        name,
853        "gear_couple" | "gear_train" | "cam_lift" | "piston" | "rack"
854    ) {
855        return Cat::Cog;
856    }
857
858    Cat::User
859}
860
861pub(crate) fn is_vtex(c: Cat) -> bool {
862    matches!(
863        c,
864        Cat::Rings
865            | Cat::Spiral
866            | Cat::Star
867            | Cat::Flower
868            | Cat::Lotus
869            | Cat::Chakra
870            | Cat::Yantra
871            | Cat::Hyper
872            | Cat::Tess
873            | Cat::Rain
874            | Cat::Grid
875            | Cat::Halftone
876            | Cat::Pagoda
877            | Cat::Torii
878            | Cat::Cog
879    )
880}
881pub(crate) fn is_audio(c: Cat) -> bool {
882    matches!(
883        c,
884        Cat::Tone | Cat::Vol | Cat::Listen | Cat::Sfx | Cat::Spectrum
885    )
886}
887pub(crate) fn is_crypto(c: Cat) -> bool {
888    matches!(
889        c,
890        Cat::Hash | Cat::Cipher | Cat::Sign | Cat::Kem | Cat::Shard
891    )
892}
893pub(crate) fn is_physics(c: Cat) -> bool {
894    matches!(
895        c,
896        Cat::Rigid | Cat::Soft | Cat::Liquid | Cat::Force | Cat::Collide
897    )
898}
899pub(crate) fn is_ai(c: Cat) -> bool {
900    matches!(c, Cat::Neural | Cat::Behavior)
901}
902
903const ENTRY_NAMES: &[&str] = &[
904    "start",
905    "main",
906    "启",
907    "เริ่ม",
908    "시작",
909    "начать",
910    "начало",
911    "inicio",
912    "comenzar",
913    "début",
914    "commencer",
915    "anfang",
916    "starten",
917    "início",
918];
919fn is_entry(name: &str) -> bool {
920    ENTRY_NAMES.contains(&name)
921}
922
923// ── Data model ────────────────────────────────────────────────────────────────
924
925struct GlobalConst {
926    name: String,
927    value: String,
928}
929
930#[derive(Clone)]
931struct CallItem {
932    name: String,
933    cat: Cat,
934    count: usize,
935}
936
937struct FuncCard {
938    name: String,
939    params: Vec<String>,
940    calls: Vec<CallItem>,
941    has_loop: bool,
942    is_entry: bool,
943    vtex_count: usize,
944    audio_count: usize,
945}
946
947struct Document {
948    filename: String,
949    globals: Vec<GlobalConst>,
950    funcs: Vec<FuncCard>,
951    #[allow(dead_code)]
952    fn_names: HashSet<String>,
953}
954
955// ── AST walking ───────────────────────────────────────────────────────────────
956
957struct RawCall {
958    name: String,
959    cat: Cat,
960}
961
962fn walk_stmts(stmts: &[Stmt], fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
963    for s in stmts {
964        let e = match s {
965            Stmt::Expr(e) | Stmt::Return(e) => e,
966            Stmt::Bind(_, e) => e,
967        };
968        walk_expr(e, fns, out, loop_);
969    }
970}
971
972fn walk_expr(e: &Expr, fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
973    match e {
974        Expr::Call(func, args) => {
975            if let Expr::Ident(name) = func.as_ref() {
976                let cat = if fns.contains(name.as_str()) {
977                    let base = categorize(name);
978                    if base == Cat::User {
979                        Cat::User
980                    } else {
981                        base
982                    }
983                } else {
984                    categorize(name)
985                };
986                out.push(RawCall { name: name.clone(), cat });
987            }
988            for a in args {
989                walk_expr(a, fns, out, loop_);
990            }
991        },
992        Expr::While { cond, body } => {
993            *loop_ = true;
994            walk_expr(cond, fns, out, loop_);
995            walk_stmts(body, fns, out, loop_);
996        },
997        Expr::Do(ss) => walk_stmts(ss, fns, out, loop_),
998        Expr::If { cond, then, elseifs, else_body } => {
999            walk_expr(cond, fns, out, loop_);
1000            walk_stmts(then, fns, out, loop_);
1001            for (c, b) in elseifs {
1002                walk_expr(c, fns, out, loop_);
1003                walk_stmts(b, fns, out, loop_);
1004            }
1005            if let Some(b) = else_body {
1006                walk_stmts(b, fns, out, loop_);
1007            }
1008        },
1009        Expr::For { iter, body, .. } => {
1010            walk_expr(iter, fns, out, loop_);
1011            walk_stmts(body, fns, out, loop_);
1012        },
1013        Expr::BinOp(_, a, b) => {
1014            walk_expr(a, fns, out, loop_);
1015            walk_expr(b, fns, out, loop_);
1016        },
1017        Expr::Array(es) => {
1018            for a in es {
1019                walk_expr(a, fns, out, loop_);
1020            }
1021        },
1022        _ => {},
1023    }
1024}
1025
1026fn aggregate(raw: Vec<RawCall>) -> Vec<CallItem> {
1027    let mut out: Vec<CallItem> = Vec::new();
1028    for r in raw {
1029        if let Some(last) = out.last_mut() {
1030            if last.name == r.name {
1031                last.count += 1;
1032                continue;
1033            }
1034        }
1035        out.push(CallItem { name: r.name, cat: r.cat, count: 1 });
1036    }
1037    out
1038}
1039
1040fn make_card(
1041    name: String,
1042    params: Vec<String>,
1043    raw: Vec<RawCall>,
1044    has_loop: bool,
1045    is_entry: bool,
1046) -> FuncCard {
1047    let calls = aggregate(raw);
1048    let vtex_count = calls
1049        .iter()
1050        .filter(|c| is_vtex(c.cat))
1051        .map(|c| c.count)
1052        .sum();
1053    let audio_count = calls
1054        .iter()
1055        .filter(|c| is_audio(c.cat))
1056        .map(|c| c.count)
1057        .sum();
1058    FuncCard {
1059        name,
1060        params,
1061        calls,
1062        has_loop,
1063        is_entry,
1064        vtex_count,
1065        audio_count,
1066    }
1067}
1068
1069impl Document {
1070    fn build(filename: &str, prog: &Program) -> Self {
1071        let fn_names: HashSet<String> = prog
1072            .items
1073            .iter()
1074            .filter_map(|i| {
1075                if let Item::Fn(f) = i {
1076                    Some(f.name.clone())
1077                } else {
1078                    None
1079                }
1080            })
1081            .collect();
1082
1083        let mut globals = Vec::new();
1084        let mut entries = Vec::new();
1085        let mut funcs = Vec::new();
1086
1087        for item in &prog.items {
1088            match item {
1089                Item::Bind(name, expr) => match expr {
1090                    Expr::Number(n) => globals.push(GlobalConst {
1091                        name: name.clone(),
1092                        value: if n.fract() == 0.0 {
1093                            format!("{}", *n as i64)
1094                        } else {
1095                            format!("{:.2}", n)
1096                        },
1097                    }),
1098                    Expr::Do(body) => {
1099                        let mut raw = Vec::new();
1100                        let mut lp = false;
1101                        // Scan inside the do block for a while loop too
1102                        for s in body {
1103                            if let Stmt::Expr(Expr::While { body: wb, .. }) = s {
1104                                lp = true;
1105                                walk_stmts(wb, &fn_names, &mut raw, &mut lp);
1106                            }
1107                        }
1108                        walk_stmts(body, &fn_names, &mut raw, &mut lp);
1109                        entries.push(make_card(name.clone(), vec![], raw, lp, is_entry(name)));
1110                    },
1111                    _ => {},
1112                },
1113                Item::Fn(f) => {
1114                    let mut raw = Vec::new();
1115                    let mut lp = false;
1116                    walk_stmts(&f.body, &fn_names, &mut raw, &mut lp);
1117                    funcs.push(make_card(f.name.clone(), f.params.clone(), raw, lp, false));
1118                },
1119                _ => {},
1120            }
1121        }
1122
1123        entries.extend(funcs);
1124        Document {
1125            filename: filename.to_string(),
1126            globals,
1127            funcs: entries,
1128            fn_names,
1129        }
1130    }
1131}
1132
1133// ── SVG icons ─────────────────────────────────────────────────────────────────
1134// Each icon is drawn centred at (cx, cy) within a radius of r≈10.
1135
1136fn xe(s: &str) -> String {
1137    s.replace('&', "&amp;")
1138        .replace('<', "&lt;")
1139        .replace('>', "&gt;")
1140}
1141fn p(v: f32) -> String {
1142    format!("{:.2}", v)
1143}
1144
1145pub(crate) fn icon(cat: Cat, cx: f32, cy: f32, r: f32) -> String {
1146    let c = cat.color();
1147    match cat {
1148        Cat::Rings => {
1149            format!(
1150                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.9"/>
1151                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.65"/>
1152                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.4"/>"#,
1153                p(cx),
1154                p(cy),
1155                p(r),
1156                p(cx),
1157                p(cy),
1158                p(r * 0.63),
1159                p(cx),
1160                p(cy),
1161                p(r * 0.33)
1162            )
1163        },
1164        Cat::Spiral => {
1165            // Approximate spiral with two arcs
1166            let (x0, y0) = (cx, cy - r * 0.1);
1167            let (x1, y1) = (cx + r * 0.85, cy);
1168            let (x2, y2) = (cx, cy + r * 0.9);
1169            let (x3, y3) = (cx - r * 0.85, cy + r * 0.1);
1170            let (x4, y4) = (cx, cy - r * 0.9);
1171            format!(
1172                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{} S {},{} {},{} S {},{} {},{}"
1173                        fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>"#,
1174                p(x0),
1175                p(y0),
1176                p(cx + r),
1177                p(y0),
1178                p(x1),
1179                p(cy - r * 0.5),
1180                p(x1),
1181                p(y1),
1182                p(cx + r * 0.3),
1183                p(y2),
1184                p(x2),
1185                p(y2),
1186                p(x3),
1187                p(y3 - r * 0.4),
1188                p(x3),
1189                p(y3),
1190                p(cx),
1191                p(y4),
1192                p(x4),
1193                p(y4)
1194            )
1195        },
1196        Cat::Star => {
1197            // 5-point star
1198            let pts: String = (0..5)
1199                .flat_map(|i| {
1200                    let ao = (i as f32 * 72.0 - 90.0).to_radians();
1201                    let ai = ao + 36.0_f32.to_radians();
1202                    let ri = r * 0.42;
1203                    vec![
1204                        format!("{},{}", p(cx + r * ao.cos()), p(cy + r * ao.sin())),
1205                        format!("{},{}", p(cx + ri * ai.cos()), p(cy + ri * ai.sin())),
1206                    ]
1207                })
1208                .collect::<Vec<_>>()
1209                .join(" ");
1210            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.85"/>"#)
1211        },
1212        Cat::Flower => {
1213            // 6 petals as ellipses
1214            (0..6)
1215                .map(|i| {
1216                    let angle = i as f32 * 60.0;
1217                    format!(
1218                        r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
1219                           fill="{c}" opacity="0.5"
1220                           transform="rotate({angle},{},{})"/>"#,
1221                        p(cx),
1222                        p(cy - r * 0.45),
1223                        p(r * 0.28),
1224                        p(r * 0.52),
1225                        p(cx),
1226                        p(cy)
1227                    )
1228                })
1229                .collect::<String>()
1230        },
1231        Cat::Lotus => {
1232            // 8 petals, more elongated
1233            (0..8)
1234                .map(|i| {
1235                    let angle = i as f32 * 45.0;
1236                    format!(
1237                        r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
1238                           fill="{c}" opacity="0.45"
1239                           transform="rotate({angle},{},{})"/>"#,
1240                        p(cx),
1241                        p(cy - r * 0.50),
1242                        p(r * 0.22),
1243                        p(r * 0.55),
1244                        p(cx),
1245                        p(cy)
1246                    )
1247                })
1248                .collect::<String>()
1249                + &format!(
1250                    r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
1251                    p(cx),
1252                    p(cy),
1253                    p(r * 0.22)
1254                )
1255        },
1256        Cat::Chakra => {
1257            // Central circle + 8 spokes
1258            let spokes: String = (0..8).map(|i| {
1259                let a = (i as f32 * 45.0).to_radians();
1260                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.8"/>"#,
1261                    p(cx + r*0.28*a.cos()), p(cy + r*0.28*a.sin()),
1262                    p(cx + r*0.88*a.cos()), p(cy + r*0.88*a.sin()))
1263            }).collect();
1264            spokes
1265                + &format!(
1266                    r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
1267                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
1268                    p(cx),
1269                    p(cy),
1270                    p(r * 0.88),
1271                    p(cx),
1272                    p(cy),
1273                    p(r * 0.2)
1274                )
1275        },
1276        Cat::Yantra => {
1277            // Hexagram (Star of David) from two triangles
1278            let h = r * 0.866;
1279            let t1 = format!(
1280                "{},{} {},{} {},{}",
1281                p(cx),
1282                p(cy - r),
1283                p(cx + h),
1284                p(cy + r * 0.5),
1285                p(cx - h),
1286                p(cy + r * 0.5)
1287            );
1288            let t2 = format!(
1289                "{},{} {},{} {},{}",
1290                p(cx),
1291                p(cy + r),
1292                p(cx - h),
1293                p(cy - r * 0.5),
1294                p(cx + h),
1295                p(cy - r * 0.5)
1296            );
1297            format!(
1298                r#"<polygon points="{t1}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1299                   <polygon points="{t2}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1300                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25"/>"#,
1301                p(cx),
1302                p(cy),
1303                p(r * 0.38)
1304            )
1305        },
1306        Cat::Hyper => {
1307            // Radial + concentric = Poincaré disc
1308            let rays: String = (0..6).map(|i| {
1309                let a = (i as f32 * 30.0).to_radians();
1310                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"#,
1311                    p(cx - r*a.cos()), p(cy - r*a.sin()),
1312                    p(cx + r*a.cos()), p(cy + r*a.sin()))
1313            }).collect();
1314            rays + &format!(
1315                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>
1316                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.5"/>
1317                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.3"/>"#,
1318                p(cx),
1319                p(cy),
1320                p(r),
1321                p(cx),
1322                p(cy),
1323                p(r * 0.6),
1324                p(cx),
1325                p(cy),
1326                p(r * 0.3)
1327            )
1328        },
1329        Cat::Tess => {
1330            // Wavy horizontal lines
1331            (0..4)
1332                .map(|i| {
1333                    let y = cy - r * 0.7 + i as f32 * r * 0.46;
1334                    let amp = r * 0.15;
1335                    format!(
1336                        r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
1337                            fill="none" stroke="{c}" stroke-width="1.1" opacity="0.7"/>"#,
1338                        p(cx - r),
1339                        p(y),
1340                        p(cx - r + r * 0.4),
1341                        p(y - amp),
1342                        p(cx - r * 0.1),
1343                        p(y - amp),
1344                        p(cx),
1345                        p(y),
1346                        p(cx + r * 0.5),
1347                        p(y + amp),
1348                        p(cx + r),
1349                        p(y)
1350                    )
1351                })
1352                .collect::<String>()
1353        },
1354        Cat::Rain => {
1355            // Falling dashes (letter rain columns)
1356            (0..5).flat_map(|col| {
1357                let x = cx - r*0.9 + col as f32 * r*0.45;
1358                (0..3).map(move |row| {
1359                    let y = cy - r*0.8 + row as f32 * r*0.55;
1360                    let opa = 0.9 - row as f32 * 0.22;
1361                    format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="{:.2}"/>"#,
1362                        p(x - r*0.06), p(y), p(r*0.12), p(r*0.30), opa)
1363                })
1364            }).collect::<String>()
1365        },
1366        Cat::Grid => {
1367            // # hash grid
1368            let n = 3;
1369            let step = r * 2.0 / n as f32;
1370            let mut s = String::new();
1371            for i in 0..=n {
1372                let off = -r + i as f32 * step;
1373                write!(s, r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>
1374                             <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>"#,
1375                    p(cx+off),p(cy-r), p(cx+off),p(cy+r),
1376                    p(cx-r),p(cy+off), p(cx+r),p(cy+off)).ok();
1377            }
1378            s
1379        },
1380        Cat::Halftone => {
1381            // 4×4 dot grid
1382            (0..4)
1383                .flat_map(|row| {
1384                    (0..4).map(move |col| {
1385                        let x = cx - r * 0.75 + col as f32 * r * 0.5;
1386                        let y = cy - r * 0.75 + row as f32 * r * 0.5;
1387                        format!(
1388                            r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.6"/>"#,
1389                            p(x),
1390                            p(y),
1391                            p(r * 0.14)
1392                        )
1393                    })
1394                })
1395                .collect::<String>()
1396        },
1397        Cat::Tone => {
1398            // Sine wave
1399            let x0 = cx - r;
1400            let x3 = cx + r;
1401            let xm = cx;
1402            let amp = r * 0.65;
1403            format!(
1404                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
1405                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>"#,
1406                p(x0),
1407                p(cy),
1408                p(x0 + r * 0.5),
1409                p(cy - amp),
1410                p(xm - r * 0.1),
1411                p(cy - amp),
1412                p(xm),
1413                p(cy),
1414                p(xm + r * 0.6),
1415                p(cy + amp),
1416                p(x3),
1417                p(cy)
1418            )
1419        },
1420        Cat::Vol | Cat::Listen => {
1421            // Concentric arcs (speaker/ear)
1422            let arcs: String = (1..=3)
1423                .map(|i| {
1424                    let ri = r * 0.3 * i as f32;
1425                    format!(
1426                        r#"<path d="M {},{} A {ri},{ri} 0 0,1 {},{}"
1427                            fill="none" stroke="{c}" stroke-width="1.3" opacity="{:.2}"/>"#,
1428                        p(cx),
1429                        p(cy - ri),
1430                        p(cx),
1431                        p(cy + ri),
1432                        1.0 - i as f32 * 0.22
1433                    )
1434                })
1435                .collect();
1436            arcs + &format!(
1437                r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.7"/>"#,
1438                p(cx - r * 0.15),
1439                p(cy),
1440                p(r * 0.22)
1441            )
1442        },
1443        Cat::Camera => {
1444            // Lens: outer circle + inner circle + crosshair dot
1445            format!(
1446                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.8"/>
1447                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.55"/>
1448                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"#,
1449                p(cx),
1450                p(cy),
1451                p(r),
1452                p(cx),
1453                p(cy),
1454                p(r * 0.6),
1455                p(cx),
1456                p(cy),
1457                p(r * 0.18)
1458            )
1459        },
1460        Cat::Light => {
1461            // Sunburst: central circle + 8 rays
1462            let rays: String = (0..8).map(|i| {
1463                let a = (i as f32 * 45.0).to_radians();
1464                let r1 = r * 0.38;
1465                let r2 = r * 0.90;
1466                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.75"/>"#,
1467                    p(cx + r1*a.cos()), p(cy + r1*a.sin()),
1468                    p(cx + r2*a.cos()), p(cy + r2*a.sin()))
1469            }).collect();
1470            rays + &format!(
1471                r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"#,
1472                p(cx),
1473                p(cy),
1474                p(r * 0.32)
1475            )
1476        },
1477        Cat::Fill => {
1478            format!(
1479                r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"#,
1480                p(cx - r * 0.9),
1481                p(cy - r * 0.7),
1482                p(r * 1.8),
1483                p(r * 1.4)
1484            )
1485        },
1486        Cat::Present => {
1487            // Triangle (play/render button)
1488            let pts = format!(
1489                "{},{} {},{} {},{}",
1490                p(cx - r * 0.7),
1491                p(cy - r * 0.8),
1492                p(cx + r * 0.8),
1493                p(cy),
1494                p(cx - r * 0.7),
1495                p(cy + r * 0.8)
1496            );
1497            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.7"/>"#)
1498        },
1499        Cat::User => {
1500            // Right-pointing chevron (function call arrow)
1501            format!(
1502                r#"<path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
1503                         stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
1504                   <path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
1505                         stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>"#,
1506                p(cx - r * 0.6),
1507                p(cy - r * 0.7),
1508                p(cx + r * 0.5),
1509                p(cy),
1510                p(cx - r * 0.6),
1511                p(cy + r * 0.7),
1512                p(cx),
1513                p(cy - r * 0.7),
1514                p(cx + r * 0.95),
1515                p(cy),
1516                p(cx),
1517                p(cy + r * 0.7)
1518            )
1519        },
1520        Cat::Loop => {
1521            // Circular arrow
1522            format!(
1523                r#"<path d="M {},{} A {},{} 0 1,1 {},{}"
1524                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
1525                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"#,
1526                p(cx),
1527                p(cy - r * 0.9),
1528                p(r * 0.9),
1529                p(r * 0.9),
1530                p(cx + r * 0.4),
1531                p(cy - r * 0.9),
1532                p(cx + r * 0.4),
1533                p(cy - r * 0.9),
1534                p(cx + r * 0.1),
1535                p(cy - r * 0.55),
1536                p(cx + r * 0.8),
1537                p(cy - r * 0.62)
1538            )
1539        },
1540        // ── Surface textures (extra) ──────────────────────────────────────────
1541        Cat::Pagoda => {
1542            let mut s = String::new();
1543            for i in 0..3 {
1544                let yy = cy - r * 0.7 + i as f32 * r * 0.55;
1545                let hw = r * (0.4 + i as f32 * 0.22);
1546                write!(s, r##"<polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85" stroke-linejoin="round"/>"##,
1547                    p(cx - hw), p(yy), p(cx), p(yy - r*0.35), p(cx + hw), p(yy)).ok();
1548            }
1549            write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"##,
1550                p(cx), p(cy - r*1.05), p(cx), p(cy + r*0.55)).ok();
1551            s
1552        },
1553        Cat::Torii => {
1554            format!(
1555                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
1556                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1557                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.5" opacity="0.9"/>
1558                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.5" opacity="0.9"/>"##,
1559                p(cx - r * 0.95),
1560                p(cy - r * 0.6),
1561                p(cx + r * 0.95),
1562                p(cy - r * 0.6),
1563                p(cx - r * 0.75),
1564                p(cy - r * 0.2),
1565                p(cx + r * 0.75),
1566                p(cy - r * 0.2),
1567                p(cx - r * 0.55),
1568                p(cy - r * 0.6),
1569                p(cx - r * 0.55),
1570                p(cy + r * 0.85),
1571                p(cx + r * 0.55),
1572                p(cy - r * 0.6),
1573                p(cx + r * 0.55),
1574                p(cy + r * 0.85)
1575            )
1576        },
1577        Cat::Cog => {
1578            let mut s = String::new();
1579            for i in 0..8 {
1580                write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.8" transform="rotate({},{},{})"/>"##,
1581                    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();
1582            }
1583            write!(
1584                s,
1585                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.55"/>
1586                         <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.9"/>"##,
1587                p(cx),
1588                p(cy),
1589                p(r * 0.7),
1590                p(cx),
1591                p(cy),
1592                p(r * 0.3)
1593            )
1594            .ok();
1595            s
1596        },
1597        Cat::Fractal => {
1598            let outer = format!(
1599                "{},{} {},{} {},{}",
1600                p(cx),
1601                p(cy - r * 0.9),
1602                p(cx + r * 0.85),
1603                p(cy + r * 0.6),
1604                p(cx - r * 0.85),
1605                p(cy + r * 0.6)
1606            );
1607            let inner = format!(
1608                "{},{} {},{} {},{}",
1609                p(cx),
1610                p(cy + r * 0.6),
1611                p(cx - r * 0.42),
1612                p(cy - r * 0.12),
1613                p(cx + r * 0.42),
1614                p(cy - r * 0.12)
1615            );
1616            format!(
1617                r##"<polygon points="{outer}" fill="{c}" opacity="0.4" stroke="{c}" stroke-width="1.0"/>
1618                   <polygon points="{inner}" fill="#0b0b1a" opacity="0.9"/>"##
1619            )
1620        },
1621        // ── Audio (extra) ─────────────────────────────────────────────────────
1622        Cat::Sfx => {
1623            format!(
1624                r##"<polygon points="{},{} {},{} {},{} {},{} {},{} {},{}" fill="{c}" opacity="0.85"/>"##,
1625                p(cx + r * 0.2),
1626                p(cy - r * 0.9),
1627                p(cx - r * 0.5),
1628                p(cy + r * 0.1),
1629                p(cx - r * 0.05),
1630                p(cy + r * 0.1),
1631                p(cx - r * 0.2),
1632                p(cy + r * 0.9),
1633                p(cx + r * 0.5),
1634                p(cy - r * 0.1),
1635                p(cx + r * 0.05),
1636                p(cy - r * 0.1)
1637            )
1638        },
1639        Cat::Spectrum => {
1640            let hs = [0.5f32, 0.9, 0.35, 0.7, 0.55];
1641            let mut s = String::new();
1642            for (i, &hh) in hs.iter().enumerate() {
1643                let xx = cx - r * 0.8 + i as f32 * r * 0.4;
1644                let bh = r * 1.6 * hh;
1645                write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="0.8"/>"##,
1646                    p(xx - r*0.12), p(cy + r*0.8 - bh), p(r*0.24), p(bh)).ok();
1647            }
1648            s
1649        },
1650        // ── Music / MIDI ──────────────────────────────────────────────────────
1651        Cat::Music => {
1652            format!(
1653                r##"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{c}" opacity="0.85" transform="rotate(-20,{},{})"/>
1654                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1655                   <path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1656                p(cx - r * 0.35),
1657                p(cy + r * 0.55),
1658                p(r * 0.32),
1659                p(r * 0.24),
1660                p(cx - r * 0.35),
1661                p(cy + r * 0.55),
1662                p(cx - r * 0.05),
1663                p(cy + r * 0.5),
1664                p(cx - r * 0.05),
1665                p(cy - r * 0.8),
1666                p(cx - r * 0.05),
1667                p(cy - r * 0.8),
1668                p(cx + r * 0.5),
1669                p(cy - r * 0.65),
1670                p(cx + r * 0.55),
1671                p(cy - r * 0.2),
1672                p(cx + r * 0.3),
1673                p(cy - r * 0.05)
1674            )
1675        },
1676        Cat::Note => {
1677            format!(
1678                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
1679                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
1680                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
1681                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
1682                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="2.0" opacity="0.85"/>"##,
1683                p(cx - r * 0.55),
1684                p(cy + r * 0.55),
1685                p(r * 0.24),
1686                p(cx + r * 0.55),
1687                p(cy + r * 0.3),
1688                p(r * 0.24),
1689                p(cx - r * 0.33),
1690                p(cy + r * 0.55),
1691                p(cx - r * 0.33),
1692                p(cy - r * 0.6),
1693                p(cx + r * 0.77),
1694                p(cy + r * 0.3),
1695                p(cx + r * 0.77),
1696                p(cy - r * 0.85),
1697                p(cx - r * 0.33),
1698                p(cy - r * 0.6),
1699                p(cx + r * 0.77),
1700                p(cy - r * 0.85)
1701            )
1702        },
1703        // ── Cryptography ──────────────────────────────────────────────────────
1704        Cat::Hash => {
1705            let mut s = String::new();
1706            for i in 0..3 {
1707                let rr = r * (0.35 + i as f32 * 0.22);
1708                write!(s, r##"<path d="M {},{} A {},{} 0 1,1 {},{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="{:.2}"/>"##,
1709                    p(cx - rr), p(cy), p(rr), p(rr), p(cx + rr), p(cy), 0.85 - i as f32*0.18).ok();
1710            }
1711            write!(
1712                s,
1713                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
1714                p(cx),
1715                p(cy),
1716                p(r * 0.12)
1717            )
1718            .ok();
1719            s
1720        },
1721        Cat::Cipher => {
1722            format!(
1723                r##"<path d="M {},{} A {},{} 0 0,1 {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.85"/>
1724                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5" stroke="{c}" stroke-width="1.1"/>
1725                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.9"/>"##,
1726                p(cx - r * 0.45),
1727                p(cy - r * 0.05),
1728                p(r * 0.45),
1729                p(r * 0.45),
1730                p(cx + r * 0.45),
1731                p(cy - r * 0.05),
1732                p(cx - r * 0.7),
1733                p(cy - r * 0.05),
1734                p(r * 1.4),
1735                p(r * 0.95),
1736                p(cx),
1737                p(cy + r * 0.4),
1738                p(r * 0.14)
1739            )
1740        },
1741        Cat::Sign => {
1742            format!(
1743                r##"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>
1744                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.5"/>"##,
1745                p(cx - r * 0.9),
1746                p(cy + r * 0.4),
1747                p(cx - r * 0.4),
1748                p(cy - r * 0.7),
1749                p(cx),
1750                p(cy + r * 0.6),
1751                p(cx + r * 0.3),
1752                p(cy - r * 0.1),
1753                p(cx + r * 0.7),
1754                p(cy - r * 0.7),
1755                p(cx + r * 0.9),
1756                p(cy + r * 0.3),
1757                p(cx - r),
1758                p(cy + r * 0.75),
1759                p(cx + r),
1760                p(cy + r * 0.75)
1761            )
1762        },
1763        Cat::Kem => {
1764            format!(
1765                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1766                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1767                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1768                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1769                p(cx - r * 0.5),
1770                p(cy - r * 0.4),
1771                p(r * 0.4),
1772                p(cx - r * 0.18),
1773                p(cy - r * 0.12),
1774                p(cx + r * 0.8),
1775                p(cy + r * 0.75),
1776                p(cx + r * 0.55),
1777                p(cy + r * 0.5),
1778                p(cx + r * 0.8),
1779                p(cy + r * 0.25),
1780                p(cx + r * 0.8),
1781                p(cy + r * 0.75),
1782                p(cx + r * 1.05),
1783                p(cy + r * 0.5)
1784            )
1785        },
1786        Cat::Shard => {
1787            format!(
1788                r##"<polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.5"/>
1789                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.3"/>
1790                   <polygon points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>"##,
1791                p(cx),
1792                p(cy - r),
1793                p(cx - r * 0.85),
1794                p(cy + r * 0.5),
1795                p(cx),
1796                p(cy + r * 0.2),
1797                p(cx),
1798                p(cy - r),
1799                p(cx + r * 0.85),
1800                p(cy + r * 0.5),
1801                p(cx),
1802                p(cy + r * 0.2),
1803                p(cx),
1804                p(cy - r),
1805                p(cx + r * 0.85),
1806                p(cy + r * 0.5),
1807                p(cx - r * 0.85),
1808                p(cy + r * 0.5)
1809            )
1810        },
1811        // ── Physics ───────────────────────────────────────────────────────────
1812        Cat::Rigid => {
1813            format!(
1814                r##"<polygon points="{},{} {},{} {},{} {},{}" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.0"/>
1815                   <path d="M {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.2" stroke="{c}" stroke-width="1.0"/>
1816                   <path d="M {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.28" stroke="{c}" stroke-width="1.0"/>"##,
1817                p(cx),
1818                p(cy - r * 0.9),
1819                p(cx + r * 0.85),
1820                p(cy - r * 0.4),
1821                p(cx),
1822                p(cy + r * 0.1),
1823                p(cx - r * 0.85),
1824                p(cy - r * 0.4),
1825                p(cx - r * 0.85),
1826                p(cy - r * 0.4),
1827                p(cx),
1828                p(cy + r * 0.1),
1829                p(cx),
1830                p(cy + r * 0.95),
1831                p(cx - r * 0.85),
1832                p(cy + r * 0.45),
1833                p(cx + r * 0.85),
1834                p(cy - r * 0.4),
1835                p(cx),
1836                p(cy + r * 0.1),
1837                p(cx),
1838                p(cy + r * 0.95),
1839                p(cx + r * 0.85),
1840                p(cy + r * 0.45)
1841            )
1842        },
1843        Cat::Soft => {
1844            format!(
1845                r##"<path d="M {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} Z" fill="{c}" opacity="0.45" stroke="{c}" stroke-width="1.0"/>"##,
1846                p(cx),
1847                p(cy - r * 0.85),
1848                p(cx + r * 0.7),
1849                p(cy - r * 0.9),
1850                p(cx + r * 0.95),
1851                p(cy - r * 0.2),
1852                p(cx + r * 0.8),
1853                p(cy + r * 0.4),
1854                p(cx + r * 0.6),
1855                p(cy + r * 0.95),
1856                p(cx - r * 0.1),
1857                p(cy + r * 0.9),
1858                p(cx - r * 0.6),
1859                p(cy + r * 0.7),
1860                p(cx - r * 0.95),
1861                p(cy + r * 0.5),
1862                p(cx - r * 0.9),
1863                p(cy - r * 0.3),
1864                p(cx - r * 0.7),
1865                p(cy - r * 0.6),
1866                p(cx - r * 0.5),
1867                p(cy - r * 0.85),
1868                p(cx - r * 0.2),
1869                p(cy - r * 0.95),
1870                p(cx),
1871                p(cy - r * 0.85)
1872            )
1873        },
1874        Cat::Liquid => {
1875            format!(
1876                r##"<path d="M {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} Z" fill="{c}" opacity="0.5" stroke="{c}" stroke-width="1.0"/>
1877                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="#ffffff" opacity="0.25"/>"##,
1878                p(cx),
1879                p(cy - r * 0.9),
1880                p(cx + r * 0.75),
1881                p(cy - r * 0.1),
1882                p(cx + r * 0.6),
1883                p(cy + r * 0.8),
1884                p(cx),
1885                p(cy + r * 0.85),
1886                p(cx - r * 0.6),
1887                p(cy + r * 0.8),
1888                p(cx - r * 0.75),
1889                p(cy - r * 0.1),
1890                p(cx),
1891                p(cy - r * 0.9),
1892                p(cx - r * 0.25),
1893                p(cy + r * 0.25),
1894                p(r * 0.16),
1895                p(r * 0.28)
1896            )
1897        },
1898        Cat::Force => {
1899            format!(
1900                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.8" opacity="0.9" stroke-linecap="round"/>
1901                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"##,
1902                p(cx - r * 0.7),
1903                p(cy + r * 0.7),
1904                p(cx + r * 0.45),
1905                p(cy - r * 0.45),
1906                p(cx + r * 0.8),
1907                p(cy - r * 0.8),
1908                p(cx + r * 0.2),
1909                p(cy - r * 0.7),
1910                p(cx + r * 0.7),
1911                p(cy - r * 0.15)
1912            )
1913        },
1914        Cat::Collide => {
1915            let mut s = String::new();
1916            for i in 0..8 {
1917                let a = (i as f32 * 45.0).to_radians();
1918                let r1 = r * 0.3;
1919                let r2 = if i % 2 == 0 { r * 0.95 } else { r * 0.6 };
1920                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1921                    p(cx + r1*a.cos()), p(cy + r1*a.sin()), p(cx + r2*a.cos()), p(cy + r2*a.sin())).ok();
1922            }
1923            write!(
1924                s,
1925                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
1926                p(cx),
1927                p(cy),
1928                p(r * 0.18)
1929            )
1930            .ok();
1931            s
1932        },
1933        // ── 3D mesh & drawing ─────────────────────────────────────────────────
1934        Cat::Mesh => {
1935            format!(
1936                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.85"/>
1937                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="none" stroke="{c}" stroke-width="0.9" opacity="0.6"/>
1938                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="none" stroke="{c}" stroke-width="0.9" opacity="0.6"/>
1939                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.9" opacity="0.6"/>"##,
1940                p(cx),
1941                p(cy),
1942                p(r * 0.9),
1943                p(cx),
1944                p(cy),
1945                p(r * 0.38),
1946                p(r * 0.9),
1947                p(cx),
1948                p(cy),
1949                p(r * 0.9),
1950                p(r * 0.38),
1951                p(cx - r * 0.9),
1952                p(cy),
1953                p(cx + r * 0.9),
1954                p(cy)
1955            )
1956        },
1957        Cat::Draw3D => {
1958            format!(
1959                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1960                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.75"/>
1961                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.6"/>
1962                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1963                p(cx),
1964                p(cy + r * 0.3),
1965                p(cx),
1966                p(cy - r * 0.9),
1967                p(cx),
1968                p(cy + r * 0.3),
1969                p(cx + r * 0.9),
1970                p(cy + r * 0.7),
1971                p(cx),
1972                p(cy + r * 0.3),
1973                p(cx - r * 0.85),
1974                p(cy + r * 0.6),
1975                p(cx),
1976                p(cy + r * 0.3),
1977                p(r * 0.13)
1978            )
1979        },
1980        Cat::Draw2D => {
1981            format!(
1982                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.6" opacity="0.9" stroke-linecap="round"/>
1983                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>
1984                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1985                p(cx - r * 0.8),
1986                p(cy + r * 0.8),
1987                p(cx + r * 0.8),
1988                p(cy - r * 0.8),
1989                p(cx - r * 0.8),
1990                p(cy + r * 0.8),
1991                p(r * 0.16),
1992                p(cx + r * 0.8),
1993                p(cy - r * 0.8),
1994                p(r * 0.16)
1995            )
1996        },
1997        // ── Math ──────────────────────────────────────────────────────────────
1998        Cat::Trig => {
1999            format!(
2000                r##"<polygon points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
2001                   <path d="M {},{} A {},{} 0 0,0 {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2002                p(cx - r * 0.8),
2003                p(cy + r * 0.7),
2004                p(cx + r * 0.8),
2005                p(cy + r * 0.7),
2006                p(cx - r * 0.8),
2007                p(cy - r * 0.7),
2008                p(cx - r * 0.35),
2009                p(cy + r * 0.7),
2010                p(r * 0.45),
2011                p(r * 0.45),
2012                p(cx - r * 0.8),
2013                p(cy + r * 0.38)
2014            )
2015        },
2016        Cat::MathFn => {
2017            format!(
2018                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.4"/>
2019                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.4"/>
2020                   <path d="M {},{} Q {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.9"/>"##,
2021                p(cx - r * 0.9),
2022                p(cy),
2023                p(cx + r * 0.9),
2024                p(cy),
2025                p(cx),
2026                p(cy - r * 0.9),
2027                p(cx),
2028                p(cy + r * 0.9),
2029                p(cx - r * 0.8),
2030                p(cy - r * 0.7),
2031                p(cx),
2032                p(cy + r * 1.1),
2033                p(cx + r * 0.8),
2034                p(cy - r * 0.7)
2035            )
2036        },
2037        Cat::Noise => {
2038            format!(
2039                r##"<polyline points="{},{} {},{} {},{} {},{} {},{} {},{} {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85" stroke-linejoin="round"/>"##,
2040                p(cx - r * 0.9),
2041                p(cy + r * 0.1),
2042                p(cx - r * 0.65),
2043                p(cy - r * 0.6),
2044                p(cx - r * 0.4),
2045                p(cy + r * 0.5),
2046                p(cx - r * 0.15),
2047                p(cy - r * 0.4),
2048                p(cx + r * 0.1),
2049                p(cy + r * 0.7),
2050                p(cx + r * 0.35),
2051                p(cy - r * 0.5),
2052                p(cx + r * 0.6),
2053                p(cy + r * 0.3),
2054                p(cx + r * 0.8),
2055                p(cy - r * 0.3),
2056                p(cx + r * 0.95),
2057                p(cy + r * 0.2)
2058            )
2059        },
2060        // ── Networking & AI ───────────────────────────────────────────────────
2061        Cat::Net => {
2062            let mut s = String::new();
2063            let nodes = [(0.0f32, -0.85f32), (0.8, 0.4), (-0.8, 0.4)];
2064            for &(nx, ny) in nodes.iter() {
2065                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>"##,
2066                    p(cx), p(cy), p(cx + nx*r), p(cy + ny*r)).ok();
2067            }
2068            write!(
2069                s,
2070                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
2071                p(cx),
2072                p(cy),
2073                p(r * 0.2)
2074            )
2075            .ok();
2076            for &(nx, ny) in nodes.iter() {
2077                write!(
2078                    s,
2079                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
2080                    p(cx + nx * r),
2081                    p(cy + ny * r),
2082                    p(r * 0.18)
2083                )
2084                .ok();
2085            }
2086            s
2087        },
2088        Cat::Neural => {
2089            let mut s = String::new();
2090            let l0 = [-0.6f32, 0.0, 0.6];
2091            let l1 = [-0.3f32, 0.3];
2092            let x0 = cx - r * 0.7;
2093            let x1 = cx;
2094            let x2 = cx + r * 0.7;
2095            for &a in l0.iter() {
2096                for &b in l1.iter() {
2097                    write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.6" opacity="0.4"/>"##,
2098                    p(x0), p(cy + a*r), p(x1), p(cy + b*r)).ok();
2099                }
2100            }
2101            for &b in l1.iter() {
2102                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.6" opacity="0.4"/>"##,
2103                    p(x1), p(cy + b*r), p(x2), p(cy)).ok();
2104            }
2105            for &a in l0.iter() {
2106                write!(
2107                    s,
2108                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2109                    p(x0),
2110                    p(cy + a * r),
2111                    p(r * 0.14)
2112                )
2113                .ok();
2114            }
2115            for &b in l1.iter() {
2116                write!(
2117                    s,
2118                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2119                    p(x1),
2120                    p(cy + b * r),
2121                    p(r * 0.14)
2122                )
2123                .ok();
2124            }
2125            write!(
2126                s,
2127                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2128                p(x2),
2129                p(cy),
2130                p(r * 0.14)
2131            )
2132            .ok();
2133            s
2134        },
2135        Cat::Behavior => {
2136            format!(
2137                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2138                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2139                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
2140                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>
2141                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
2142                p(cx),
2143                p(cy - r * 0.6),
2144                p(cx - r * 0.7),
2145                p(cy + r * 0.6),
2146                p(cx),
2147                p(cy - r * 0.6),
2148                p(cx + r * 0.7),
2149                p(cy + r * 0.6),
2150                p(cx),
2151                p(cy - r * 0.6),
2152                p(r * 0.2),
2153                p(cx - r * 0.7),
2154                p(cy + r * 0.6),
2155                p(r * 0.18),
2156                p(cx + r * 0.7),
2157                p(cy + r * 0.6),
2158                p(r * 0.18)
2159            )
2160        },
2161        // ── UI / dialog / holograms ───────────────────────────────────────────
2162        Cat::Widget => {
2163            format!(
2164                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
2165                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>
2166                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"##,
2167                p(cx - r * 0.85),
2168                p(cy - r * 0.75),
2169                p(r * 1.7),
2170                p(r * 1.5),
2171                p(cx - r * 0.85),
2172                p(cy - r * 0.3),
2173                p(cx + r * 0.85),
2174                p(cy - r * 0.3),
2175                p(cx - r * 0.5),
2176                p(cy + r * 0.1),
2177                p(r * 1.0),
2178                p(r * 0.45)
2179            )
2180        },
2181        Cat::Hud => {
2182            format!(
2183                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.85"/>
2184                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.6"/>
2185                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.6"/>
2186                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.9"/>
2187                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
2188                p(cx),
2189                p(cy),
2190                p(r * 0.9),
2191                p(cx - r * 0.9),
2192                p(cy),
2193                p(cx + r * 0.9),
2194                p(cy),
2195                p(cx),
2196                p(cy - r * 0.9),
2197                p(cx),
2198                p(cy + r * 0.9),
2199                p(cx),
2200                p(cy),
2201                p(cx + r * 0.64),
2202                p(cy - r * 0.64),
2203                p(cx + r * 0.4),
2204                p(cy - r * 0.4),
2205                p(r * 0.13)
2206            )
2207        },
2208        Cat::Dialog => {
2209            format!(
2210                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="4" fill="{c}" opacity="0.4" stroke="{c}" stroke-width="1.0"/>
2211                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.4"/>
2212                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>
2213                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>
2214                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>"##,
2215                p(cx - r * 0.9),
2216                p(cy - r * 0.8),
2217                p(r * 1.8),
2218                p(r * 1.2),
2219                p(cx - r * 0.4),
2220                p(cy + r * 0.4),
2221                p(cx - r * 0.1),
2222                p(cy + r * 0.4),
2223                p(cx - r * 0.55),
2224                p(cy + r * 0.9),
2225                p(cx - r * 0.4),
2226                p(cy - r * 0.2),
2227                p(r * 0.1),
2228                p(cx),
2229                p(cy - r * 0.2),
2230                p(r * 0.1),
2231                p(cx + r * 0.4),
2232                p(cy - r * 0.2),
2233                p(r * 0.1)
2234            )
2235        },
2236        Cat::Holo => {
2237            let mut s = String::new();
2238            for i in 0..3 {
2239                let off = (i as f32 - 1.0) * r * 0.22;
2240                write!(s, r##"<polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.3" stroke="{c}" stroke-width="0.8" stroke-opacity="0.7"/>"##,
2241                    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();
2242            }
2243            s
2244        },
2245        // ── Text & vector ─────────────────────────────────────────────────────
2246        Cat::Str => {
2247            let mut s = String::new();
2248            for &dx in [-0.5f32, 0.3].iter() {
2249                write!(
2250                    s,
2251                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
2252                             <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2253                    p(cx + dx * r),
2254                    p(cy - r * 0.4),
2255                    p(r * 0.16),
2256                    p(cx + dx * r + r * 0.3),
2257                    p(cy - r * 0.4),
2258                    p(r * 0.16)
2259                )
2260                .ok();
2261            }
2262            for j in 0..2 {
2263                let yy = cy + r * 0.3 + j as f32 * r * 0.45;
2264                let ww = if j == 0 { r * 1.6 } else { r * 1.0 };
2265                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.6"/>"##,
2266                    p(cx - r*0.8), p(yy), p(cx - r*0.8 + ww), p(yy)).ok();
2267            }
2268            s
2269        },
2270        Cat::Font => {
2271            format!(
2272                r##"<polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.9" stroke-linejoin="round"/>
2273                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
2274                p(cx - r * 0.7),
2275                p(cy + r * 0.8),
2276                p(cx),
2277                p(cy - r * 0.85),
2278                p(cx + r * 0.7),
2279                p(cy + r * 0.8),
2280                p(cx - r * 0.38),
2281                p(cy + r * 0.15),
2282                p(cx + r * 0.38),
2283                p(cy + r * 0.15)
2284            )
2285        },
2286        Cat::Vector => {
2287            format!(
2288                r##"<path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
2289                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.7" opacity="0.5"/>
2290                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.7" opacity="0.5"/>
2291                   <rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.85"/>
2292                   <rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.85"/>
2293                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2294                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2295                p(cx - r * 0.8),
2296                p(cy + r * 0.7),
2297                p(cx - r * 0.3),
2298                p(cy - r * 0.9),
2299                p(cx + r * 0.3),
2300                p(cy - r * 0.9),
2301                p(cx + r * 0.8),
2302                p(cy + r * 0.7),
2303                p(cx - r * 0.8),
2304                p(cy + r * 0.7),
2305                p(cx - r * 0.3),
2306                p(cy - r * 0.9),
2307                p(cx + r * 0.8),
2308                p(cy + r * 0.7),
2309                p(cx + r * 0.3),
2310                p(cy - r * 0.9),
2311                p(cx - r * 0.9),
2312                p(cy + r * 0.6),
2313                p(r * 0.2),
2314                p(r * 0.2),
2315                p(cx + r * 0.7),
2316                p(cy + r * 0.6),
2317                p(r * 0.2),
2318                p(r * 0.2),
2319                p(cx - r * 0.3),
2320                p(cy - r * 0.9),
2321                p(r * 0.14),
2322                p(cx + r * 0.3),
2323                p(cy - r * 0.9),
2324                p(r * 0.14)
2325            )
2326        },
2327        // ── Colour / shading / scene ──────────────────────────────────────────
2328        Cat::Color => {
2329            format!(
2330                r##"<circle cx="{}" cy="{}" r="{}" fill="#ff5e8a" opacity="0.5"/>
2331                   <circle cx="{}" cy="{}" r="{}" fill="#50fa7b" opacity="0.5"/>
2332                   <circle cx="{}" cy="{}" r="{}" fill="#6ab0f5" opacity="0.5"/>"##,
2333                p(cx),
2334                p(cy - r * 0.4),
2335                p(r * 0.6),
2336                p(cx - r * 0.45),
2337                p(cy + r * 0.35),
2338                p(r * 0.6),
2339                p(cx + r * 0.45),
2340                p(cy + r * 0.35),
2341                p(r * 0.6)
2342            )
2343        },
2344        Cat::Shade => {
2345            format!(
2346                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25" stroke="{c}" stroke-width="1.0"/>
2347                   <path d="M {},{} A {},{} 0 0,1 {},{} A {},{} 0 0,0 {},{} Z" fill="{c}" opacity="0.6"/>
2348                   <circle cx="{}" cy="{}" r="{}" fill="#ffffff" opacity="0.3"/>"##,
2349                p(cx),
2350                p(cy),
2351                p(r * 0.9),
2352                p(cx),
2353                p(cy - r * 0.9),
2354                p(r * 0.9),
2355                p(r * 0.9),
2356                p(cx),
2357                p(cy + r * 0.9),
2358                p(r * 0.45),
2359                p(r * 0.9),
2360                p(cx),
2361                p(cy - r * 0.9),
2362                p(cx + r * 0.35),
2363                p(cy - r * 0.35),
2364                p(r * 0.18)
2365            )
2366        },
2367        Cat::Window => {
2368            format!(
2369                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
2370                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2371                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2372                p(cx - r * 0.85),
2373                p(cy - r * 0.85),
2374                p(r * 1.7),
2375                p(r * 1.7),
2376                p(cx),
2377                p(cy - r * 0.85),
2378                p(cx),
2379                p(cy + r * 0.85),
2380                p(cx - r * 0.85),
2381                p(cy),
2382                p(cx + r * 0.85),
2383                p(cy)
2384            )
2385        },
2386        // ── Input ─────────────────────────────────────────────────────────────
2387        Cat::Key => {
2388            format!(
2389                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="3" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.2"/>
2390                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2391                p(cx - r * 0.8),
2392                p(cy - r * 0.8),
2393                p(r * 1.6),
2394                p(r * 1.6),
2395                p(cx - r * 0.45),
2396                p(cy - r * 0.45),
2397                p(r * 0.9),
2398                p(r * 0.9)
2399            )
2400        },
2401        Cat::Mouse => {
2402            format!(
2403                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
2404                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>
2405                   <rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="0.85"/>"##,
2406                p(cx - r * 0.55),
2407                p(cy - r * 0.85),
2408                p(r * 1.1),
2409                p(r * 1.7),
2410                p(r * 0.55),
2411                p(cx),
2412                p(cy - r * 0.85),
2413                p(cx),
2414                p(cy - r * 0.1),
2415                p(cx - r * 0.08),
2416                p(cy - r * 0.7),
2417                p(r * 0.16),
2418                p(r * 0.4)
2419            )
2420        },
2421        // ── IO / system / timing ──────────────────────────────────────────────
2422        Cat::Print => {
2423            let mut s = format!(
2424                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="1.5" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>"##,
2425                p(cx - r * 0.6),
2426                p(cy - r * 0.85),
2427                p(r * 1.2),
2428                p(r * 1.7)
2429            );
2430            for j in 0..4 {
2431                let yy = cy - r * 0.45 + j as f32 * r * 0.35;
2432                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>"##,
2433                    p(cx - r*0.4), p(yy), p(cx + r*0.4), p(yy)).ok();
2434            }
2435            s
2436        },
2437        Cat::File => {
2438            format!(
2439                r##"<path d="M {},{} L {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.1"/>
2440                   <polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>"##,
2441                p(cx - r * 0.6),
2442                p(cy - r * 0.85),
2443                p(cx + r * 0.25),
2444                p(cy - r * 0.85),
2445                p(cx + r * 0.6),
2446                p(cy - r * 0.45),
2447                p(cx + r * 0.6),
2448                p(cy + r * 0.85),
2449                p(cx - r * 0.6),
2450                p(cy + r * 0.85),
2451                p(cx + r * 0.25),
2452                p(cy - r * 0.85),
2453                p(cx + r * 0.25),
2454                p(cy - r * 0.45),
2455                p(cx + r * 0.6),
2456                p(cy - r * 0.45)
2457            )
2458        },
2459        Cat::Sys => {
2460            format!(
2461                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.8"/>
2462                   <polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.9" stroke-linecap="round" stroke-linejoin="round"/>
2463                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.9" stroke-linecap="round"/>"##,
2464                p(cx - r * 0.9),
2465                p(cy - r * 0.7),
2466                p(r * 1.8),
2467                p(r * 1.4),
2468                p(cx - r * 0.45),
2469                p(cy - r * 0.2),
2470                p(cx - r * 0.1),
2471                p(cy + r * 0.15),
2472                p(cx - r * 0.45),
2473                p(cy + r * 0.5),
2474                p(cx + r * 0.1),
2475                p(cy + r * 0.5),
2476                p(cx + r * 0.5),
2477                p(cy + r * 0.5)
2478            )
2479        },
2480        Cat::Anim => {
2481            let mut s = String::new();
2482            for i in 0..6 {
2483                let a = (i as f32 * 60.0).to_radians();
2484                let rad = r * 0.7;
2485                let dotr = r * (0.08 + 0.16 * (i as f32 / 6.0));
2486                write!(
2487                    s,
2488                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="{:.2}"/>"##,
2489                    p(cx + rad * a.cos()),
2490                    p(cy + rad * a.sin()),
2491                    p(dotr),
2492                    0.35 + i as f32 * 0.1
2493                )
2494                .ok();
2495            }
2496            s
2497        },
2498        Cat::Const => {
2499            format!(
2500                r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2"
2501                           fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
2502                       <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.1" opacity="0.5"/>"#,
2503                p(cx - r * 0.7),
2504                p(cy - r * 0.55),
2505                p(r * 1.4),
2506                p(r * 1.1),
2507                p(cx - r * 0.4),
2508                p(cy),
2509                p(cx + r * 0.4),
2510                p(cy)
2511            )
2512        },
2513    }
2514}
2515
2516// ── Card rendering ────────────────────────────────────────────────────────────
2517
2518fn card_height(card: &FuncCard) -> f32 {
2519    let icon_rows = (card.calls.len() + ICONS_ROW - 1).max(1) / ICONS_ROW + 1;
2520    let base = CARD_PAD * 2.0          // top+bottom padding
2521        + 32.0                          // name header
2522        + 20.0; // params + stats row
2523    base + icon_rows as f32 * (ICON_SZ + ICON_GAP) + ICON_GAP
2524}
2525
2526fn render_card(card: &FuncCard, x: f32, y: f32) -> String {
2527    let h = card_height(card);
2528    let w = CARD_W;
2529    let r = CARD_ROUNDING;
2530
2531    // Dominant colour from most-common call category (by raw count)
2532    let dominant = card
2533        .calls
2534        .iter()
2535        .max_by_key(|c| c.count)
2536        .map(|c| c.cat.color())
2537        .unwrap_or(Cat::User.color());
2538
2539    let border_color = if card.is_entry { GOLD } else { dominant };
2540    let border_w = if card.is_entry { 2.5 } else { 1.2 };
2541    let glow_filter = if card.is_entry {
2542        r#" filter="url(#glow-gold)""#
2543    } else {
2544        ""
2545    };
2546
2547    let mut s = String::new();
2548
2549    // Card background
2550    write!(s, r#"<rect x="{}" y="{}" width="{w}" height="{h}" rx="{r}"
2551                      fill="{CARD_BG}" stroke="{border_color}" stroke-width="{border_w}"{glow_filter}/>
2552                 <line x1="{}" y1="{}" x2="{}" y2="{}"
2553                      stroke="{border_color}" stroke-width="3" opacity="0.6"/>
2554"#,
2555        p(x), p(y),
2556        p(x+r), p(y+h), p(x+r), p(y)   // left accent stripe
2557    ).ok();
2558
2559    // Function name
2560    let name_y = y + CARD_PAD + 18.0;
2561    let entry_badge = if card.is_entry {
2562        format!(
2563            r#" <text x="{}" y="{}" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.8">⬡ ENTRY</text>"#,
2564            p(x + w - CARD_PAD - 48.0),
2565            p(name_y)
2566        )
2567    } else {
2568        String::new()
2569    };
2570
2571    write!(
2572        s,
2573        r#"<text x="{}" y="{}" fill="{}" font-size="13" font-weight="bold">{}</text>{}"#,
2574        p(x + CARD_PAD + 10.0),
2575        p(name_y),
2576        if card.is_entry { GOLD } else { TEXT },
2577        xe(&card.name),
2578        entry_badge
2579    )
2580    .ok();
2581
2582    // Params + stats row
2583    let stats_y = name_y + 18.0;
2584    let params_str = if card.params.is_empty() {
2585        String::new()
2586    } else {
2587        format!("({})", card.params.join(", "))
2588    };
2589    let stats_str = {
2590        let mut parts = Vec::new();
2591        if card.vtex_count > 0 {
2592            parts.push(format!("{} vtex", card.vtex_count));
2593        }
2594        if card.audio_count > 0 {
2595            parts.push(format!("{} audio", card.audio_count));
2596        }
2597        if card.has_loop {
2598            parts.push("↺ loop".into());
2599        }
2600        parts.join("  ·  ")
2601    };
2602    write!(
2603        s,
2604        r#"<text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{}</text>
2605                 <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10" text-anchor="end">{}</text>
2606"#,
2607        p(x + CARD_PAD + 10.0),
2608        p(stats_y),
2609        xe(&params_str),
2610        p(x + w - CARD_PAD),
2611        p(stats_y),
2612        xe(&stats_str)
2613    )
2614    .ok();
2615
2616    // Divider
2617    let div_y = stats_y + 6.0;
2618    write!(
2619        s,
2620        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{CARD_BD}" stroke-width="1"/>"#,
2621        p(x + CARD_PAD),
2622        p(div_y),
2623        p(x + w - CARD_PAD),
2624        p(div_y)
2625    )
2626    .ok();
2627
2628    // Icons
2629    let icon_y0 = div_y + ICON_GAP + ICON_SZ / 2.0;
2630    let icon_x0 = x + CARD_PAD + ICON_SZ / 2.0 + 6.0;
2631    let ir = ICON_SZ / 2.0 * 0.82; // icon radius slightly smaller than half-cell
2632
2633    for (i, call) in card.calls.iter().enumerate() {
2634        let row = i / ICONS_ROW;
2635        let col = i % ICONS_ROW;
2636        let ix = icon_x0 + col as f32 * (ICON_SZ + ICON_GAP);
2637        let iy = icon_y0 + row as f32 * (ICON_SZ + ICON_GAP);
2638
2639        // Icon cell background
2640        write!(
2641            s,
2642            r#"<rect x="{}" y="{}" width="{ICON_SZ}" height="{ICON_SZ}" rx="3"
2643                          fill="{}" opacity="0.12"/>
2644"#,
2645            p(ix - ICON_SZ / 2.0),
2646            p(iy - ICON_SZ / 2.0),
2647            call.cat.color()
2648        )
2649        .ok();
2650
2651        // Icon shape
2652        s.push_str(&icon(call.cat, ix, iy, ir));
2653
2654        // Count badge (if > 1)
2655        if call.count > 1 {
2656            write!(s, r##"<rect x="{}" y="{}" width="13" height="10" rx="3" fill="{}" opacity="0.9"/>
2657                         <text x="{}" y="{}" fill="#0a0a1a" font-size="8" font-weight="bold" text-anchor="middle">{}</text>"##,
2658                p(ix + ir - 2.0), p(iy - ir - 1.0), call.cat.color(),
2659                p(ix + ir + 4.5), p(iy - ir + 7.0), call.count
2660            ).ok();
2661        }
2662    }
2663
2664    s
2665}
2666
2667// ── Legend, header, sidebar ───────────────────────────────────────────────────
2668
2669fn render_header(filename: &str, funcs: &[FuncCard]) -> String {
2670    let name = std::path::Path::new(filename)
2671        .file_name()
2672        .map(|n| n.to_string_lossy().into_owned())
2673        .unwrap_or_else(|| filename.to_string());
2674    let n_fns = funcs.len();
2675    let count = |pred: fn(Cat) -> bool| -> usize {
2676        funcs
2677            .iter()
2678            .flat_map(|f| &f.calls)
2679            .filter(|c| pred(c.cat))
2680            .map(|c| c.count)
2681            .sum()
2682    };
2683    let n_vtex = count(is_vtex);
2684    let n_audio = count(is_audio);
2685    let n_crypto = count(is_crypto);
2686    let n_physics = count(is_physics);
2687    let n_ai = count(is_ai);
2688    let n_calls: usize = funcs.iter().map(|f| f.calls.len()).sum();
2689
2690    // Domain stat chips — only show those actually present, so the header stays tight.
2691    let mut stats = vec![format!("{n_fns} fn"), format!("{n_calls} call types")];
2692    for (n, lbl) in [
2693        (n_vtex, "vtex"),
2694        (n_audio, "audio"),
2695        (n_crypto, "crypto"),
2696        (n_physics, "physics"),
2697        (n_ai, "ai"),
2698    ] {
2699        if n > 0 {
2700            stats.push(format!("{n} {lbl}"));
2701        }
2702    }
2703
2704    format!(
2705        r##"<rect x="0" y="0" width="{SVG_W}" height="{HEADER_H}" fill="#080814"/>
2706           <line x1="0" y1="{HEADER_H}" x2="{SVG_W}" y2="{HEADER_H}" stroke="#1a1a3a" stroke-width="1"/>
2707           <text x="20" y="22" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.6" letter-spacing="2">LING VISUALIZER</text>
2708           <text x="20" y="56" fill="{TEXT}" font-size="26" font-weight="bold">{}</text>
2709           <text x="{}" y="56" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"##,
2710        xe(&name),
2711        SVG_W - 20.0,
2712        stats.join("  ·  ")
2713    )
2714}
2715
2716fn render_legend(funcs: &[FuncCard]) -> String {
2717    // Collect present categories, then order them by coarse domain so related
2718    // icons cluster together; a faint tag introduces each domain group.
2719    let mut present: Vec<Cat> = Vec::new();
2720    let mut seen = HashSet::new();
2721    for f in funcs {
2722        for c in &f.calls {
2723            if seen.insert(c.cat) {
2724                present.push(c.cat);
2725            }
2726        }
2727    }
2728    present.sort_by(|a, b| a.domain().cmp(b.domain()).then(a.label().cmp(b.label())));
2729
2730    let mut s = format!(
2731        r##"<rect x="0" y="{HEADER_H}" width="{SVG_W}" height="{LEGEND_H}" fill="#0a0a18"/>
2732           <line x1="0" y1="{}" x2="{SVG_W}" y2="{}" stroke="#171730" stroke-width="1"/>"##,
2733        HEADER_H + LEGEND_H,
2734        HEADER_H + LEGEND_H
2735    );
2736
2737    let row_h = 21.0;
2738    let x_start = GRID_X + 8.0;
2739    let x_max = SVG_W - 24.0;
2740    let mut lx = x_start;
2741    let mut row = 0usize;
2742    let mut cur_domain = "";
2743    let row_y = |r: usize| HEADER_H + 17.0 + r as f32 * row_h;
2744
2745    for cat in present {
2746        let c = cat.color();
2747        let lbl = cat.label();
2748        let dom = cat.domain();
2749
2750        // Domain tag whenever the group changes (and a little extra lead space).
2751        if dom != cur_domain {
2752            let tag_w = dom.len() as f32 * 6.0 + 12.0;
2753            if lx > x_start && lx + tag_w > x_max {
2754                row += 1;
2755                lx = x_start;
2756            }
2757            if row > 1 {
2758                break;
2759            } // at most two rows in the band
2760            let ly = row_y(row);
2761            s.push_str(&format!(
2762                r##"<text x="{}" y="{}" fill="#3f3f66" font-size="8" font-weight="bold" letter-spacing="1">{}</text>"##,
2763                p(lx), p(ly + 3.0), dom.to_uppercase()
2764            ));
2765            lx += tag_w;
2766            cur_domain = dom;
2767        }
2768
2769        let item_w = 20.0 + lbl.len() as f32 * 6.2 + 12.0;
2770        if lx + item_w > x_max {
2771            row += 1;
2772            lx = x_start;
2773        }
2774        if row > 1 {
2775            break;
2776        }
2777        let ly = row_y(row);
2778        let icon_svg = icon(cat, lx + 7.0, ly, 6.0);
2779        s.push_str(&format!(
2780            r#"<rect x="{}" y="{}" width="14" height="14" rx="3" fill="{c}" opacity="0.12"/>
2781               {}
2782               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{lbl}</text>"#,
2783            p(lx),
2784            p(ly - 7.0),
2785            icon_svg,
2786            p(lx + 18.0),
2787            p(ly + 4.0)
2788        ));
2789        lx += item_w;
2790    }
2791    s
2792}
2793
2794fn render_sidebar(globals: &[GlobalConst], total_h: f32) -> String {
2795    let h = total_h - CONTENT_Y;
2796    let mut s = format!(
2797        r##"<rect x="0" y="{CONTENT_Y}" width="{SIDEBAR_W}" height="{h}" fill="{SIDEBAR_BG}"/>
2798           <line x1="{SIDEBAR_W}" y1="{CONTENT_Y}" x2="{SIDEBAR_W}" y2="{total_h}" stroke="#1a1a3a" stroke-width="1"/>
2799           <text x="14" y="{}" fill="{TEXT_DIM}" font-size="9" font-weight="bold" letter-spacing="2">CONSTANTS</text>"##,
2800        CONTENT_Y + 20.0
2801    );
2802
2803    let mut gy = CONTENT_Y + 38.0;
2804    for g in globals {
2805        // Small colored diamond
2806        write!(
2807            s,
2808            r#"<rect x="{}" y="{}" width="8" height="8" rx="1" fill="{}" opacity="0.7"
2809                    transform="rotate(45,{},{})"/>
2810               <text x="{}" y="{}" fill="{}" font-size="12" font-weight="bold">{}</text>
2811               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"#,
2812            p(14.0),
2813            p(gy - 7.0),
2814            Cat::Star.color(),
2815            p(18.0),
2816            p(gy - 3.0),
2817            p(28.0),
2818            p(gy),
2819            Cat::Grid.color(),
2820            xe(&g.name),
2821            p(SIDEBAR_W - 10.0),
2822            p(gy),
2823            xe(&g.value)
2824        )
2825        .ok();
2826        gy += 22.0;
2827    }
2828    s
2829}
2830
2831// ── SVG defs (filters, patterns) ─────────────────────────────────────────────
2832
2833const DEFS: &str = r##"<defs>
2834  <filter id="glow-gold" x="-30%" y="-30%" width="160%" height="160%">
2835    <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
2836    <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
2837  </filter>
2838  <pattern id="grid-pat" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
2839    <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#ffffff" stroke-width="0.4"/>
2840  </pattern>
2841</defs>"##;
2842
2843// ── Public entry point ────────────────────────────────────────────────────────
2844
2845pub fn render(filename: &str, program: &Program) -> String {
2846    let doc = Document::build(filename, program);
2847
2848    // Layout: pack cards into COLS columns using shortest-column strategy
2849    let heights: Vec<f32> = doc.funcs.iter().map(card_height).collect();
2850    let mut col_y = [CONTENT_Y; COLS];
2851    let mut positions: Vec<(f32, f32)> = Vec::with_capacity(doc.funcs.len());
2852
2853    for &h in &heights {
2854        let (col, &cy) = col_y
2855            .iter()
2856            .enumerate()
2857            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
2858            .unwrap();
2859        let cx = GRID_X + col as f32 * (CARD_W + CARD_GAP);
2860        positions.push((cx, cy));
2861        col_y[col] += h + CARD_GAP;
2862    }
2863
2864    let total_h = col_y.iter().cloned().fold(0.0f32, f32::max) + 40.0;
2865
2866    let mut svg = String::new();
2867    write!(
2868        svg,
2869        r#"<?xml version="1.0" encoding="UTF-8"?>
2870<svg xmlns="http://www.w3.org/2000/svg" width="{SVG_W}" height="{h}" viewBox="0 0 {SVG_W} {h}"
2871     style="font-family:'JetBrains Mono','Fira Code',monospace,sans-serif;background:{BG}">"#,
2872        h = total_h
2873    )
2874    .ok();
2875
2876    svg.push_str(DEFS);
2877
2878    // Background
2879    write!(svg, r#"<rect width="{SVG_W}" height="{total_h}" fill="{BG}"/>
2880                   <rect width="{SVG_W}" height="{total_h}" fill="url(#grid-pat)" opacity="0.05"/>"#,
2881        total_h = total_h).ok();
2882
2883    svg.push_str(&render_header(&doc.filename, &doc.funcs));
2884    svg.push_str(&render_legend(&doc.funcs));
2885    svg.push_str(&render_sidebar(&doc.globals, total_h));
2886
2887    for (card, &(cx, cy)) in doc.funcs.iter().zip(positions.iter()) {
2888        svg.push_str(&render_card(card, cx, cy));
2889    }
2890
2891    svg.push_str("\n</svg>");
2892    svg
2893}