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    ) {
588        return Cat::Draw3D;
589    }
590
591    // ── Neural / behavior / dialog (AI) ──
592    if name.starts_with("nn_") {
593        return Cat::Neural;
594    }
595    if name.starts_with("bt_") {
596        return Cat::Behavior;
597    }
598    if name.starts_with("dialog_") {
599        return Cat::Dialog;
600    }
601
602    // ── Networking ──
603    if name.starts_with("net_") {
604        return Cat::Net;
605    }
606
607    // ── UI widgets / HUD ──
608    if matches!(
609        name,
610        "ui_radar"
611            | "ui_radar3d"
612            | "ui_minimap"
613            | "ui_compass"
614            | "ui_healthbar"
615            | "ui_reticle"
616            | "ui_target"
617            | "ui_gauge"
618            | "ui_gauge3d"
619            | "ui_vu"
620            | "ui_battery"
621            | "ui_segbar"
622            | "ui_bar"
623            | "ui_progress"
624            | "ui_cooldown"
625            | "ui_scanlines"
626            | "ui_vignette"
627            | "ui_spark"
628            | "ui_ring"
629            | "ui_counter"
630    ) {
631        return Cat::Hud;
632    }
633    if name.starts_with("ui_") {
634        return Cat::Widget;
635    }
636
637    // ── Holograms / particles ──
638    if matches!(
639        name,
640        "holo_points" | "holo_fragment_count" | "knot_points" | "sparkle" | "neon" | "psychedelic"
641    ) {
642        return Cat::Holo;
643    }
644
645    // ── Fonts & vector text ──
646    if name.starts_with("font_") {
647        return Cat::Font;
648    }
649    if name.starts_with("svg_") {
650        return Cat::Vector;
651    }
652
653    // ── Strings ──
654    if name.starts_with("str_")
655        || matches!(
656            name,
657            "split"
658                | "join"
659                | "trim"
660                | "substr"
661                | "format"
662                | "to_str"
663                | "num_str"
664                | "starts_with"
665                | "ends_with"
666        )
667    {
668        return Cat::Str;
669    }
670
671    // ── Math: trig / noise / general ──
672    if matches!(
673        name,
674        "sin"
675            | "cos"
676            | "tan"
677            | "asin"
678            | "acos"
679            | "atan"
680            | "atan2"
681            | "arcsin"
682            | "arccos"
683            | "arctan"
684            | "arctan2"
685            | "tanh"
686            | "tanhf"
687            | "hypot"
688    ) {
689        return Cat::Trig;
690    }
691    if matches!(
692        name,
693        "perlin" | "perlin3" | "noise2" | "fbm" | "vnoise" | "smoothstep"
694    ) {
695        return Cat::Noise;
696    }
697    if matches!(
698        name,
699        "sqrt"
700            | "cbrt"
701            | "pow"
702            | "exp"
703            | "ln"
704            | "log"
705            | "log2"
706            | "log10"
707            | "abs"
708            | "floor"
709            | "ceil"
710            | "round"
711            | "trunc"
712            | "fract"
713            | "sign"
714            | "clamp"
715            | "lerp"
716            | "min"
717            | "max"
718            | "rand"
719            | "pi"
720            | "tau"
721    ) {
722        return Cat::MathFn;
723    }
724
725    // ── Colour & shading ──
726    if matches!(
727        name,
728        "hsl_color"
729            | "hsv_to_rgb"
730            | "set_color"
731            | "set_color_hsl"
732            | "color"
733            | "lerp_color"
734            | "gfx_color"
735    ) {
736        return Cat::Color;
737    }
738    if matches!(
739        name,
740        "set_shade_mode"
741            | "set_rim"
742            | "set_fog"
743            | "set_ambient"
744            | "set_cel_bands"
745            | "set_blend"
746            | "set_shadow_color"
747            | "set_projection"
748            | "set_zdist"
749    ) {
750        return Cat::Shade;
751    }
752
753    // ── Camera / lights ──
754    if matches!(name, "set_camera" | "set_camera_pos" | "move_camera") {
755        return Cat::Camera;
756    }
757    if matches!(name, "add_light" | "clear_lights") {
758        return Cat::Light;
759    }
760
761    // ── Window / present / fill ──
762    if matches!(
763        name,
764        "open_window"
765            | "open_fullscreen"
766            | "gfx_window"
767            | "fullscreen"
768            | "windowed"
769            | "wait_window"
770            | "is_open"
771            | "window_is_open"
772            | "gfx_is_open"
773            | "gfx_wait"
774            | "resolution"
775            | "get_width"
776            | "get_height"
777    ) {
778        return Cat::Window;
779    }
780    if matches!(
781        name,
782        "present" | "แสดงผล" | "gfx_present" | "render" | "render_3d" | "flush_3d"
783    ) {
784        return Cat::Present;
785    }
786    if matches!(name, "fill" | "เติม" | "gfx_fill" | "clear") {
787        return Cat::Fill;
788    }
789
790    // ── 2D drawing ──
791    if name.starts_with("draw_")
792        || matches!(
793            name,
794            "gfx_line" | "gfx_pixel" | "gfx_triangle" | "line" | "triangle" | "pixel"
795        )
796    {
797        return Cat::Draw2D;
798    }
799
800    // ── Input ──
801    if name.starts_with("mouse_") || matches!(name, "capture_mouse" | "release_mouse") {
802        return Cat::Mouse;
803    }
804    if name.starts_with("key_") || matches!(name, "keys" | "text_poll" | "text_get") {
805        return Cat::Key;
806    }
807
808    // ── IO / system ──
809    if matches!(
810        name,
811        "print" | "println" | "print_file" | "imprimir" | "afficher" | "вывести" | "พิมพ์" | "打印"
812    ) {
813        return Cat::Print;
814    }
815    if matches!(name, "read_file" | "write_file" | "อ่านไฟล์" | "เขียนไฟล์")
816    {
817        return Cat::File;
818    }
819    if matches!(
820        name,
821        "system" | "get_args" | "time_now" | "sleep" | "sleep_ms"
822    ) {
823        return Cat::Sys;
824    }
825
826    // ── Animation / timing (Anima organic drivers + classic timing) ──
827    if matches!(
828        name,
829        "animation"
830            | "ease"
831            | "tick"
832            | "delta_time"
833            | "frame_count"
834            | "frame"
835            | "record_frame"
836            | "record_count"
837            | "tween"
838            | "tween_ease"
839            | "breathe"
840            | "wobble"
841            | "gait_phase"
842            | "gait_swing"
843            | "gait_lift"
844            | "spring_to"
845            | "ik2"
846    ) {
847        return Cat::Anim;
848    }
849    // ── Anima mechanical drivers — reuse the gear (Cog) glyph ──
850    if matches!(
851        name,
852        "gear_couple" | "gear_train" | "cam_lift" | "piston" | "rack"
853    ) {
854        return Cat::Cog;
855    }
856
857    Cat::User
858}
859
860pub(crate) fn is_vtex(c: Cat) -> bool {
861    matches!(
862        c,
863        Cat::Rings
864            | Cat::Spiral
865            | Cat::Star
866            | Cat::Flower
867            | Cat::Lotus
868            | Cat::Chakra
869            | Cat::Yantra
870            | Cat::Hyper
871            | Cat::Tess
872            | Cat::Rain
873            | Cat::Grid
874            | Cat::Halftone
875            | Cat::Pagoda
876            | Cat::Torii
877            | Cat::Cog
878    )
879}
880pub(crate) fn is_audio(c: Cat) -> bool {
881    matches!(
882        c,
883        Cat::Tone | Cat::Vol | Cat::Listen | Cat::Sfx | Cat::Spectrum
884    )
885}
886pub(crate) fn is_crypto(c: Cat) -> bool {
887    matches!(
888        c,
889        Cat::Hash | Cat::Cipher | Cat::Sign | Cat::Kem | Cat::Shard
890    )
891}
892pub(crate) fn is_physics(c: Cat) -> bool {
893    matches!(
894        c,
895        Cat::Rigid | Cat::Soft | Cat::Liquid | Cat::Force | Cat::Collide
896    )
897}
898pub(crate) fn is_ai(c: Cat) -> bool {
899    matches!(c, Cat::Neural | Cat::Behavior)
900}
901
902const ENTRY_NAMES: &[&str] = &[
903    "start",
904    "main",
905    "启",
906    "เริ่ม",
907    "시작",
908    "начать",
909    "начало",
910    "inicio",
911    "comenzar",
912    "début",
913    "commencer",
914    "anfang",
915    "starten",
916    "início",
917];
918fn is_entry(name: &str) -> bool {
919    ENTRY_NAMES.contains(&name)
920}
921
922// ── Data model ────────────────────────────────────────────────────────────────
923
924struct GlobalConst {
925    name: String,
926    value: String,
927}
928
929#[derive(Clone)]
930struct CallItem {
931    name: String,
932    cat: Cat,
933    count: usize,
934}
935
936struct FuncCard {
937    name: String,
938    params: Vec<String>,
939    calls: Vec<CallItem>,
940    has_loop: bool,
941    is_entry: bool,
942    vtex_count: usize,
943    audio_count: usize,
944}
945
946struct Document {
947    filename: String,
948    globals: Vec<GlobalConst>,
949    funcs: Vec<FuncCard>,
950    #[allow(dead_code)]
951    fn_names: HashSet<String>,
952}
953
954// ── AST walking ───────────────────────────────────────────────────────────────
955
956struct RawCall {
957    name: String,
958    cat: Cat,
959}
960
961fn walk_stmts(stmts: &[Stmt], fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
962    for s in stmts {
963        let e = match s {
964            Stmt::Expr(e) | Stmt::Return(e) => e,
965            Stmt::Bind(_, e) => e,
966        };
967        walk_expr(e, fns, out, loop_);
968    }
969}
970
971fn walk_expr(e: &Expr, fns: &HashSet<String>, out: &mut Vec<RawCall>, loop_: &mut bool) {
972    match e {
973        Expr::Call(func, args) => {
974            if let Expr::Ident(name) = func.as_ref() {
975                let cat = if fns.contains(name.as_str()) {
976                    let base = categorize(name);
977                    if base == Cat::User {
978                        Cat::User
979                    } else {
980                        base
981                    }
982                } else {
983                    categorize(name)
984                };
985                out.push(RawCall { name: name.clone(), cat });
986            }
987            for a in args {
988                walk_expr(a, fns, out, loop_);
989            }
990        },
991        Expr::While { cond, body } => {
992            *loop_ = true;
993            walk_expr(cond, fns, out, loop_);
994            walk_stmts(body, fns, out, loop_);
995        },
996        Expr::Do(ss) => walk_stmts(ss, fns, out, loop_),
997        Expr::If { cond, then, elseifs, else_body } => {
998            walk_expr(cond, fns, out, loop_);
999            walk_stmts(then, fns, out, loop_);
1000            for (c, b) in elseifs {
1001                walk_expr(c, fns, out, loop_);
1002                walk_stmts(b, fns, out, loop_);
1003            }
1004            if let Some(b) = else_body {
1005                walk_stmts(b, fns, out, loop_);
1006            }
1007        },
1008        Expr::For { iter, body, .. } => {
1009            walk_expr(iter, fns, out, loop_);
1010            walk_stmts(body, fns, out, loop_);
1011        },
1012        Expr::BinOp(_, a, b) => {
1013            walk_expr(a, fns, out, loop_);
1014            walk_expr(b, fns, out, loop_);
1015        },
1016        Expr::Array(es) => {
1017            for a in es {
1018                walk_expr(a, fns, out, loop_);
1019            }
1020        },
1021        _ => {},
1022    }
1023}
1024
1025fn aggregate(raw: Vec<RawCall>) -> Vec<CallItem> {
1026    let mut out: Vec<CallItem> = Vec::new();
1027    for r in raw {
1028        if let Some(last) = out.last_mut() {
1029            if last.name == r.name {
1030                last.count += 1;
1031                continue;
1032            }
1033        }
1034        out.push(CallItem { name: r.name, cat: r.cat, count: 1 });
1035    }
1036    out
1037}
1038
1039fn make_card(
1040    name: String,
1041    params: Vec<String>,
1042    raw: Vec<RawCall>,
1043    has_loop: bool,
1044    is_entry: bool,
1045) -> FuncCard {
1046    let calls = aggregate(raw);
1047    let vtex_count = calls
1048        .iter()
1049        .filter(|c| is_vtex(c.cat))
1050        .map(|c| c.count)
1051        .sum();
1052    let audio_count = calls
1053        .iter()
1054        .filter(|c| is_audio(c.cat))
1055        .map(|c| c.count)
1056        .sum();
1057    FuncCard {
1058        name,
1059        params,
1060        calls,
1061        has_loop,
1062        is_entry,
1063        vtex_count,
1064        audio_count,
1065    }
1066}
1067
1068impl Document {
1069    fn build(filename: &str, prog: &Program) -> Self {
1070        let fn_names: HashSet<String> = prog
1071            .items
1072            .iter()
1073            .filter_map(|i| {
1074                if let Item::Fn(f) = i {
1075                    Some(f.name.clone())
1076                } else {
1077                    None
1078                }
1079            })
1080            .collect();
1081
1082        let mut globals = Vec::new();
1083        let mut entries = Vec::new();
1084        let mut funcs = Vec::new();
1085
1086        for item in &prog.items {
1087            match item {
1088                Item::Bind(name, expr) => match expr {
1089                    Expr::Number(n) => globals.push(GlobalConst {
1090                        name: name.clone(),
1091                        value: if n.fract() == 0.0 {
1092                            format!("{}", *n as i64)
1093                        } else {
1094                            format!("{:.2}", n)
1095                        },
1096                    }),
1097                    Expr::Do(body) => {
1098                        let mut raw = Vec::new();
1099                        let mut lp = false;
1100                        // Scan inside the do block for a while loop too
1101                        for s in body {
1102                            if let Stmt::Expr(Expr::While { body: wb, .. }) = s {
1103                                lp = true;
1104                                walk_stmts(wb, &fn_names, &mut raw, &mut lp);
1105                            }
1106                        }
1107                        walk_stmts(body, &fn_names, &mut raw, &mut lp);
1108                        entries.push(make_card(name.clone(), vec![], raw, lp, is_entry(name)));
1109                    },
1110                    _ => {},
1111                },
1112                Item::Fn(f) => {
1113                    let mut raw = Vec::new();
1114                    let mut lp = false;
1115                    walk_stmts(&f.body, &fn_names, &mut raw, &mut lp);
1116                    funcs.push(make_card(f.name.clone(), f.params.clone(), raw, lp, false));
1117                },
1118                _ => {},
1119            }
1120        }
1121
1122        entries.extend(funcs);
1123        Document {
1124            filename: filename.to_string(),
1125            globals,
1126            funcs: entries,
1127            fn_names,
1128        }
1129    }
1130}
1131
1132// ── SVG icons ─────────────────────────────────────────────────────────────────
1133// Each icon is drawn centred at (cx, cy) within a radius of r≈10.
1134
1135fn xe(s: &str) -> String {
1136    s.replace('&', "&amp;")
1137        .replace('<', "&lt;")
1138        .replace('>', "&gt;")
1139}
1140fn p(v: f32) -> String {
1141    format!("{:.2}", v)
1142}
1143
1144pub(crate) fn icon(cat: Cat, cx: f32, cy: f32, r: f32) -> String {
1145    let c = cat.color();
1146    match cat {
1147        Cat::Rings => {
1148            format!(
1149                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.9"/>
1150                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.65"/>
1151                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.4"/>"#,
1152                p(cx),
1153                p(cy),
1154                p(r),
1155                p(cx),
1156                p(cy),
1157                p(r * 0.63),
1158                p(cx),
1159                p(cy),
1160                p(r * 0.33)
1161            )
1162        },
1163        Cat::Spiral => {
1164            // Approximate spiral with two arcs
1165            let (x0, y0) = (cx, cy - r * 0.1);
1166            let (x1, y1) = (cx + r * 0.85, cy);
1167            let (x2, y2) = (cx, cy + r * 0.9);
1168            let (x3, y3) = (cx - r * 0.85, cy + r * 0.1);
1169            let (x4, y4) = (cx, cy - r * 0.9);
1170            format!(
1171                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{} S {},{} {},{} S {},{} {},{}"
1172                        fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>"#,
1173                p(x0),
1174                p(y0),
1175                p(cx + r),
1176                p(y0),
1177                p(x1),
1178                p(cy - r * 0.5),
1179                p(x1),
1180                p(y1),
1181                p(cx + r * 0.3),
1182                p(y2),
1183                p(x2),
1184                p(y2),
1185                p(x3),
1186                p(y3 - r * 0.4),
1187                p(x3),
1188                p(y3),
1189                p(cx),
1190                p(y4),
1191                p(x4),
1192                p(y4)
1193            )
1194        },
1195        Cat::Star => {
1196            // 5-point star
1197            let pts: String = (0..5)
1198                .flat_map(|i| {
1199                    let ao = (i as f32 * 72.0 - 90.0).to_radians();
1200                    let ai = ao + 36.0_f32.to_radians();
1201                    let ri = r * 0.42;
1202                    vec![
1203                        format!("{},{}", p(cx + r * ao.cos()), p(cy + r * ao.sin())),
1204                        format!("{},{}", p(cx + ri * ai.cos()), p(cy + ri * ai.sin())),
1205                    ]
1206                })
1207                .collect::<Vec<_>>()
1208                .join(" ");
1209            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.85"/>"#)
1210        },
1211        Cat::Flower => {
1212            // 6 petals as ellipses
1213            (0..6)
1214                .map(|i| {
1215                    let angle = i as f32 * 60.0;
1216                    format!(
1217                        r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
1218                           fill="{c}" opacity="0.5"
1219                           transform="rotate({angle},{},{})"/>"#,
1220                        p(cx),
1221                        p(cy - r * 0.45),
1222                        p(r * 0.28),
1223                        p(r * 0.52),
1224                        p(cx),
1225                        p(cy)
1226                    )
1227                })
1228                .collect::<String>()
1229        },
1230        Cat::Lotus => {
1231            // 8 petals, more elongated
1232            (0..8)
1233                .map(|i| {
1234                    let angle = i as f32 * 45.0;
1235                    format!(
1236                        r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}"
1237                           fill="{c}" opacity="0.45"
1238                           transform="rotate({angle},{},{})"/>"#,
1239                        p(cx),
1240                        p(cy - r * 0.50),
1241                        p(r * 0.22),
1242                        p(r * 0.55),
1243                        p(cx),
1244                        p(cy)
1245                    )
1246                })
1247                .collect::<String>()
1248                + &format!(
1249                    r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
1250                    p(cx),
1251                    p(cy),
1252                    p(r * 0.22)
1253                )
1254        },
1255        Cat::Chakra => {
1256            // Central circle + 8 spokes
1257            let spokes: String = (0..8).map(|i| {
1258                let a = (i as f32 * 45.0).to_radians();
1259                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.8"/>"#,
1260                    p(cx + r*0.28*a.cos()), p(cy + r*0.28*a.sin()),
1261                    p(cx + r*0.88*a.cos()), p(cy + r*0.88*a.sin()))
1262            }).collect();
1263            spokes
1264                + &format!(
1265                    r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
1266                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"#,
1267                    p(cx),
1268                    p(cy),
1269                    p(r * 0.88),
1270                    p(cx),
1271                    p(cy),
1272                    p(r * 0.2)
1273                )
1274        },
1275        Cat::Yantra => {
1276            // Hexagram (Star of David) from two triangles
1277            let h = r * 0.866;
1278            let t1 = format!(
1279                "{},{} {},{} {},{}",
1280                p(cx),
1281                p(cy - r),
1282                p(cx + h),
1283                p(cy + r * 0.5),
1284                p(cx - h),
1285                p(cy + r * 0.5)
1286            );
1287            let t2 = format!(
1288                "{},{} {},{} {},{}",
1289                p(cx),
1290                p(cy + r),
1291                p(cx - h),
1292                p(cy - r * 0.5),
1293                p(cx + h),
1294                p(cy - r * 0.5)
1295            );
1296            format!(
1297                r#"<polygon points="{t1}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1298                   <polygon points="{t2}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1299                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25"/>"#,
1300                p(cx),
1301                p(cy),
1302                p(r * 0.38)
1303            )
1304        },
1305        Cat::Hyper => {
1306            // Radial + concentric = Poincaré disc
1307            let rays: String = (0..6).map(|i| {
1308                let a = (i as f32 * 30.0).to_radians();
1309                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"#,
1310                    p(cx - r*a.cos()), p(cy - r*a.sin()),
1311                    p(cx + r*a.cos()), p(cy + r*a.sin()))
1312            }).collect();
1313            rays + &format!(
1314                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>
1315                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.5"/>
1316                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.3"/>"#,
1317                p(cx),
1318                p(cy),
1319                p(r),
1320                p(cx),
1321                p(cy),
1322                p(r * 0.6),
1323                p(cx),
1324                p(cy),
1325                p(r * 0.3)
1326            )
1327        },
1328        Cat::Tess => {
1329            // Wavy horizontal lines
1330            (0..4)
1331                .map(|i| {
1332                    let y = cy - r * 0.7 + i as f32 * r * 0.46;
1333                    let amp = r * 0.15;
1334                    format!(
1335                        r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
1336                            fill="none" stroke="{c}" stroke-width="1.1" opacity="0.7"/>"#,
1337                        p(cx - r),
1338                        p(y),
1339                        p(cx - r + r * 0.4),
1340                        p(y - amp),
1341                        p(cx - r * 0.1),
1342                        p(y - amp),
1343                        p(cx),
1344                        p(y),
1345                        p(cx + r * 0.5),
1346                        p(y + amp),
1347                        p(cx + r),
1348                        p(y)
1349                    )
1350                })
1351                .collect::<String>()
1352        },
1353        Cat::Rain => {
1354            // Falling dashes (letter rain columns)
1355            (0..5).flat_map(|col| {
1356                let x = cx - r*0.9 + col as f32 * r*0.45;
1357                (0..3).map(move |row| {
1358                    let y = cy - r*0.8 + row as f32 * r*0.55;
1359                    let opa = 0.9 - row as f32 * 0.22;
1360                    format!(r#"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="{:.2}"/>"#,
1361                        p(x - r*0.06), p(y), p(r*0.12), p(r*0.30), opa)
1362                })
1363            }).collect::<String>()
1364        },
1365        Cat::Grid => {
1366            // # hash grid
1367            let n = 3;
1368            let step = r * 2.0 / n as f32;
1369            let mut s = String::new();
1370            for i in 0..=n {
1371                let off = -r + i as f32 * step;
1372                write!(s, r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>
1373                             <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.65"/>"#,
1374                    p(cx+off),p(cy-r), p(cx+off),p(cy+r),
1375                    p(cx-r),p(cy+off), p(cx+r),p(cy+off)).ok();
1376            }
1377            s
1378        },
1379        Cat::Halftone => {
1380            // 4×4 dot grid
1381            (0..4)
1382                .flat_map(|row| {
1383                    (0..4).map(move |col| {
1384                        let x = cx - r * 0.75 + col as f32 * r * 0.5;
1385                        let y = cy - r * 0.75 + row as f32 * r * 0.5;
1386                        format!(
1387                            r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.6"/>"#,
1388                            p(x),
1389                            p(y),
1390                            p(r * 0.14)
1391                        )
1392                    })
1393                })
1394                .collect::<String>()
1395        },
1396        Cat::Tone => {
1397            // Sine wave
1398            let x0 = cx - r;
1399            let x3 = cx + r;
1400            let xm = cx;
1401            let amp = r * 0.65;
1402            format!(
1403                r#"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}"
1404                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>"#,
1405                p(x0),
1406                p(cy),
1407                p(x0 + r * 0.5),
1408                p(cy - amp),
1409                p(xm - r * 0.1),
1410                p(cy - amp),
1411                p(xm),
1412                p(cy),
1413                p(xm + r * 0.6),
1414                p(cy + amp),
1415                p(x3),
1416                p(cy)
1417            )
1418        },
1419        Cat::Vol | Cat::Listen => {
1420            // Concentric arcs (speaker/ear)
1421            let arcs: String = (1..=3)
1422                .map(|i| {
1423                    let ri = r * 0.3 * i as f32;
1424                    format!(
1425                        r#"<path d="M {},{} A {ri},{ri} 0 0,1 {},{}"
1426                            fill="none" stroke="{c}" stroke-width="1.3" opacity="{:.2}"/>"#,
1427                        p(cx),
1428                        p(cy - ri),
1429                        p(cx),
1430                        p(cy + ri),
1431                        1.0 - i as f32 * 0.22
1432                    )
1433                })
1434                .collect();
1435            arcs + &format!(
1436                r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.7"/>"#,
1437                p(cx - r * 0.15),
1438                p(cy),
1439                p(r * 0.22)
1440            )
1441        },
1442        Cat::Camera => {
1443            // Lens: outer circle + inner circle + crosshair dot
1444            format!(
1445                r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.8"/>
1446                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.55"/>
1447                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"#,
1448                p(cx),
1449                p(cy),
1450                p(r),
1451                p(cx),
1452                p(cy),
1453                p(r * 0.6),
1454                p(cx),
1455                p(cy),
1456                p(r * 0.18)
1457            )
1458        },
1459        Cat::Light => {
1460            // Sunburst: central circle + 8 rays
1461            let rays: String = (0..8).map(|i| {
1462                let a = (i as f32 * 45.0).to_radians();
1463                let r1 = r * 0.38;
1464                let r2 = r * 0.90;
1465                format!(r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.75"/>"#,
1466                    p(cx + r1*a.cos()), p(cy + r1*a.sin()),
1467                    p(cx + r2*a.cos()), p(cy + r2*a.sin()))
1468            }).collect();
1469            rays + &format!(
1470                r#"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"#,
1471                p(cx),
1472                p(cy),
1473                p(r * 0.32)
1474            )
1475        },
1476        Cat::Fill => {
1477            format!(
1478                r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"#,
1479                p(cx - r * 0.9),
1480                p(cy - r * 0.7),
1481                p(r * 1.8),
1482                p(r * 1.4)
1483            )
1484        },
1485        Cat::Present => {
1486            // Triangle (play/render button)
1487            let pts = format!(
1488                "{},{} {},{} {},{}",
1489                p(cx - r * 0.7),
1490                p(cy - r * 0.8),
1491                p(cx + r * 0.8),
1492                p(cy),
1493                p(cx - r * 0.7),
1494                p(cy + r * 0.8)
1495            );
1496            format!(r#"<polygon points="{pts}" fill="{c}" opacity="0.7"/>"#)
1497        },
1498        Cat::User => {
1499            // Right-pointing chevron (function call arrow)
1500            format!(
1501                r#"<path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
1502                         stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
1503                   <path d="M {},{} L {},{} L {},{}" fill="none" stroke="{c}" stroke-width="1.8"
1504                         stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>"#,
1505                p(cx - r * 0.6),
1506                p(cy - r * 0.7),
1507                p(cx + r * 0.5),
1508                p(cy),
1509                p(cx - r * 0.6),
1510                p(cy + r * 0.7),
1511                p(cx),
1512                p(cy - r * 0.7),
1513                p(cx + r * 0.95),
1514                p(cy),
1515                p(cx),
1516                p(cy + r * 0.7)
1517            )
1518        },
1519        Cat::Loop => {
1520            // Circular arrow
1521            format!(
1522                r#"<path d="M {},{} A {},{} 0 1,1 {},{}"
1523                        fill="none" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
1524                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"#,
1525                p(cx),
1526                p(cy - r * 0.9),
1527                p(r * 0.9),
1528                p(r * 0.9),
1529                p(cx + r * 0.4),
1530                p(cy - r * 0.9),
1531                p(cx + r * 0.4),
1532                p(cy - r * 0.9),
1533                p(cx + r * 0.1),
1534                p(cy - r * 0.55),
1535                p(cx + r * 0.8),
1536                p(cy - r * 0.62)
1537            )
1538        },
1539        // ── Surface textures (extra) ──────────────────────────────────────────
1540        Cat::Pagoda => {
1541            let mut s = String::new();
1542            for i in 0..3 {
1543                let yy = cy - r * 0.7 + i as f32 * r * 0.55;
1544                let hw = r * (0.4 + i as f32 * 0.22);
1545                write!(s, r##"<polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85" stroke-linejoin="round"/>"##,
1546                    p(cx - hw), p(yy), p(cx), p(yy - r*0.35), p(cx + hw), p(yy)).ok();
1547            }
1548            write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.55"/>"##,
1549                p(cx), p(cy - r*1.05), p(cx), p(cy + r*0.55)).ok();
1550            s
1551        },
1552        Cat::Torii => {
1553            format!(
1554                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.6" opacity="0.9"/>
1555                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1556                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.5" opacity="0.9"/>
1557                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.5" opacity="0.9"/>"##,
1558                p(cx - r * 0.95),
1559                p(cy - r * 0.6),
1560                p(cx + r * 0.95),
1561                p(cy - r * 0.6),
1562                p(cx - r * 0.75),
1563                p(cy - r * 0.2),
1564                p(cx + r * 0.75),
1565                p(cy - r * 0.2),
1566                p(cx - r * 0.55),
1567                p(cy - r * 0.6),
1568                p(cx - r * 0.55),
1569                p(cy + r * 0.85),
1570                p(cx + r * 0.55),
1571                p(cy - r * 0.6),
1572                p(cx + r * 0.55),
1573                p(cy + r * 0.85)
1574            )
1575        },
1576        Cat::Cog => {
1577            let mut s = String::new();
1578            for i in 0..8 {
1579                write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.8" transform="rotate({},{},{})"/>"##,
1580                    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();
1581            }
1582            write!(
1583                s,
1584                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.55"/>
1585                         <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.9"/>"##,
1586                p(cx),
1587                p(cy),
1588                p(r * 0.7),
1589                p(cx),
1590                p(cy),
1591                p(r * 0.3)
1592            )
1593            .ok();
1594            s
1595        },
1596        Cat::Fractal => {
1597            let outer = format!(
1598                "{},{} {},{} {},{}",
1599                p(cx),
1600                p(cy - r * 0.9),
1601                p(cx + r * 0.85),
1602                p(cy + r * 0.6),
1603                p(cx - r * 0.85),
1604                p(cy + r * 0.6)
1605            );
1606            let inner = format!(
1607                "{},{} {},{} {},{}",
1608                p(cx),
1609                p(cy + r * 0.6),
1610                p(cx - r * 0.42),
1611                p(cy - r * 0.12),
1612                p(cx + r * 0.42),
1613                p(cy - r * 0.12)
1614            );
1615            format!(
1616                r##"<polygon points="{outer}" fill="{c}" opacity="0.4" stroke="{c}" stroke-width="1.0"/>
1617                   <polygon points="{inner}" fill="#0b0b1a" opacity="0.9"/>"##
1618            )
1619        },
1620        // ── Audio (extra) ─────────────────────────────────────────────────────
1621        Cat::Sfx => {
1622            format!(
1623                r##"<polygon points="{},{} {},{} {},{} {},{} {},{} {},{}" fill="{c}" opacity="0.85"/>"##,
1624                p(cx + r * 0.2),
1625                p(cy - r * 0.9),
1626                p(cx - r * 0.5),
1627                p(cy + r * 0.1),
1628                p(cx - r * 0.05),
1629                p(cy + r * 0.1),
1630                p(cx - r * 0.2),
1631                p(cy + r * 0.9),
1632                p(cx + r * 0.5),
1633                p(cy - r * 0.1),
1634                p(cx + r * 0.05),
1635                p(cy - r * 0.1)
1636            )
1637        },
1638        Cat::Spectrum => {
1639            let hs = [0.5f32, 0.9, 0.35, 0.7, 0.55];
1640            let mut s = String::new();
1641            for (i, &hh) in hs.iter().enumerate() {
1642                let xx = cx - r * 0.8 + i as f32 * r * 0.4;
1643                let bh = r * 1.6 * hh;
1644                write!(s, r##"<rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="0.8"/>"##,
1645                    p(xx - r*0.12), p(cy + r*0.8 - bh), p(r*0.24), p(bh)).ok();
1646            }
1647            s
1648        },
1649        // ── Music / MIDI ──────────────────────────────────────────────────────
1650        Cat::Music => {
1651            format!(
1652                r##"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{c}" opacity="0.85" transform="rotate(-20,{},{})"/>
1653                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1654                   <path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1655                p(cx - r * 0.35),
1656                p(cy + r * 0.55),
1657                p(r * 0.32),
1658                p(r * 0.24),
1659                p(cx - r * 0.35),
1660                p(cy + r * 0.55),
1661                p(cx - r * 0.05),
1662                p(cy + r * 0.5),
1663                p(cx - r * 0.05),
1664                p(cy - r * 0.8),
1665                p(cx - r * 0.05),
1666                p(cy - r * 0.8),
1667                p(cx + r * 0.5),
1668                p(cy - r * 0.65),
1669                p(cx + r * 0.55),
1670                p(cy - r * 0.2),
1671                p(cx + r * 0.3),
1672                p(cy - r * 0.05)
1673            )
1674        },
1675        Cat::Note => {
1676            format!(
1677                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
1678                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
1679                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" 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="2.0" opacity="0.85"/>"##,
1682                p(cx - r * 0.55),
1683                p(cy + r * 0.55),
1684                p(r * 0.24),
1685                p(cx + r * 0.55),
1686                p(cy + r * 0.3),
1687                p(r * 0.24),
1688                p(cx - r * 0.33),
1689                p(cy + r * 0.55),
1690                p(cx - r * 0.33),
1691                p(cy - r * 0.6),
1692                p(cx + r * 0.77),
1693                p(cy + r * 0.3),
1694                p(cx + r * 0.77),
1695                p(cy - r * 0.85),
1696                p(cx - r * 0.33),
1697                p(cy - r * 0.6),
1698                p(cx + r * 0.77),
1699                p(cy - r * 0.85)
1700            )
1701        },
1702        // ── Cryptography ──────────────────────────────────────────────────────
1703        Cat::Hash => {
1704            let mut s = String::new();
1705            for i in 0..3 {
1706                let rr = r * (0.35 + i as f32 * 0.22);
1707                write!(s, r##"<path d="M {},{} A {},{} 0 1,1 {},{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="{:.2}"/>"##,
1708                    p(cx - rr), p(cy), p(rr), p(rr), p(cx + rr), p(cy), 0.85 - i as f32*0.18).ok();
1709            }
1710            write!(
1711                s,
1712                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
1713                p(cx),
1714                p(cy),
1715                p(r * 0.12)
1716            )
1717            .ok();
1718            s
1719        },
1720        Cat::Cipher => {
1721            format!(
1722                r##"<path d="M {},{} A {},{} 0 0,1 {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.85"/>
1723                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5" stroke="{c}" stroke-width="1.1"/>
1724                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.9"/>"##,
1725                p(cx - r * 0.45),
1726                p(cy - r * 0.05),
1727                p(r * 0.45),
1728                p(r * 0.45),
1729                p(cx + r * 0.45),
1730                p(cy - r * 0.05),
1731                p(cx - r * 0.7),
1732                p(cy - r * 0.05),
1733                p(r * 1.4),
1734                p(r * 0.95),
1735                p(cx),
1736                p(cy + r * 0.4),
1737                p(r * 0.14)
1738            )
1739        },
1740        Cat::Sign => {
1741            format!(
1742                r##"<path d="M {},{} C {},{} {},{} {},{} S {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9" stroke-linecap="round"/>
1743                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.5"/>"##,
1744                p(cx - r * 0.9),
1745                p(cy + r * 0.4),
1746                p(cx - r * 0.4),
1747                p(cy - r * 0.7),
1748                p(cx),
1749                p(cy + r * 0.6),
1750                p(cx + r * 0.3),
1751                p(cy - r * 0.1),
1752                p(cx + r * 0.7),
1753                p(cy - r * 0.7),
1754                p(cx + r * 0.9),
1755                p(cy + r * 0.3),
1756                p(cx - r),
1757                p(cy + r * 0.75),
1758                p(cx + r),
1759                p(cy + r * 0.75)
1760            )
1761        },
1762        Cat::Kem => {
1763            format!(
1764                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1765                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1766                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
1767                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1768                p(cx - r * 0.5),
1769                p(cy - r * 0.4),
1770                p(r * 0.4),
1771                p(cx - r * 0.18),
1772                p(cy - r * 0.12),
1773                p(cx + r * 0.8),
1774                p(cy + r * 0.75),
1775                p(cx + r * 0.55),
1776                p(cy + r * 0.5),
1777                p(cx + r * 0.8),
1778                p(cy + r * 0.25),
1779                p(cx + r * 0.8),
1780                p(cy + r * 0.75),
1781                p(cx + r * 1.05),
1782                p(cy + r * 0.5)
1783            )
1784        },
1785        Cat::Shard => {
1786            format!(
1787                r##"<polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.5"/>
1788                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.3"/>
1789                   <polygon points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>"##,
1790                p(cx),
1791                p(cy - r),
1792                p(cx - r * 0.85),
1793                p(cy + r * 0.5),
1794                p(cx),
1795                p(cy + r * 0.2),
1796                p(cx),
1797                p(cy - r),
1798                p(cx + r * 0.85),
1799                p(cy + r * 0.5),
1800                p(cx),
1801                p(cy + r * 0.2),
1802                p(cx),
1803                p(cy - r),
1804                p(cx + r * 0.85),
1805                p(cy + r * 0.5),
1806                p(cx - r * 0.85),
1807                p(cy + r * 0.5)
1808            )
1809        },
1810        // ── Physics ───────────────────────────────────────────────────────────
1811        Cat::Rigid => {
1812            format!(
1813                r##"<polygon points="{},{} {},{} {},{} {},{}" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.0"/>
1814                   <path d="M {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.2" stroke="{c}" stroke-width="1.0"/>
1815                   <path d="M {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.28" stroke="{c}" stroke-width="1.0"/>"##,
1816                p(cx),
1817                p(cy - r * 0.9),
1818                p(cx + r * 0.85),
1819                p(cy - r * 0.4),
1820                p(cx),
1821                p(cy + r * 0.1),
1822                p(cx - r * 0.85),
1823                p(cy - r * 0.4),
1824                p(cx - r * 0.85),
1825                p(cy - r * 0.4),
1826                p(cx),
1827                p(cy + r * 0.1),
1828                p(cx),
1829                p(cy + r * 0.95),
1830                p(cx - r * 0.85),
1831                p(cy + r * 0.45),
1832                p(cx + r * 0.85),
1833                p(cy - r * 0.4),
1834                p(cx),
1835                p(cy + r * 0.1),
1836                p(cx),
1837                p(cy + r * 0.95),
1838                p(cx + r * 0.85),
1839                p(cy + r * 0.45)
1840            )
1841        },
1842        Cat::Soft => {
1843            format!(
1844                r##"<path d="M {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} Z" fill="{c}" opacity="0.45" stroke="{c}" stroke-width="1.0"/>"##,
1845                p(cx),
1846                p(cy - r * 0.85),
1847                p(cx + r * 0.7),
1848                p(cy - r * 0.9),
1849                p(cx + r * 0.95),
1850                p(cy - r * 0.2),
1851                p(cx + r * 0.8),
1852                p(cy + r * 0.4),
1853                p(cx + r * 0.6),
1854                p(cy + r * 0.95),
1855                p(cx - r * 0.1),
1856                p(cy + r * 0.9),
1857                p(cx - r * 0.6),
1858                p(cy + r * 0.7),
1859                p(cx - r * 0.95),
1860                p(cy + r * 0.5),
1861                p(cx - r * 0.9),
1862                p(cy - r * 0.3),
1863                p(cx - r * 0.7),
1864                p(cy - r * 0.6),
1865                p(cx - r * 0.5),
1866                p(cy - r * 0.85),
1867                p(cx - r * 0.2),
1868                p(cy - r * 0.95),
1869                p(cx),
1870                p(cy - r * 0.85)
1871            )
1872        },
1873        Cat::Liquid => {
1874            format!(
1875                r##"<path d="M {},{} C {},{} {},{} {},{} C {},{} {},{} {},{} Z" fill="{c}" opacity="0.5" stroke="{c}" stroke-width="1.0"/>
1876                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="#ffffff" opacity="0.25"/>"##,
1877                p(cx),
1878                p(cy - r * 0.9),
1879                p(cx + r * 0.75),
1880                p(cy - r * 0.1),
1881                p(cx + r * 0.6),
1882                p(cy + r * 0.8),
1883                p(cx),
1884                p(cy + r * 0.85),
1885                p(cx - r * 0.6),
1886                p(cy + r * 0.8),
1887                p(cx - r * 0.75),
1888                p(cy - r * 0.1),
1889                p(cx),
1890                p(cy - r * 0.9),
1891                p(cx - r * 0.25),
1892                p(cy + r * 0.25),
1893                p(r * 0.16),
1894                p(r * 0.28)
1895            )
1896        },
1897        Cat::Force => {
1898            format!(
1899                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.8" opacity="0.9" stroke-linecap="round"/>
1900                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.9"/>"##,
1901                p(cx - r * 0.7),
1902                p(cy + r * 0.7),
1903                p(cx + r * 0.45),
1904                p(cy - r * 0.45),
1905                p(cx + r * 0.8),
1906                p(cy - r * 0.8),
1907                p(cx + r * 0.2),
1908                p(cy - r * 0.7),
1909                p(cx + r * 0.7),
1910                p(cy - r * 0.15)
1911            )
1912        },
1913        Cat::Collide => {
1914            let mut s = String::new();
1915            for i in 0..8 {
1916                let a = (i as f32 * 45.0).to_radians();
1917                let r1 = r * 0.3;
1918                let r2 = if i % 2 == 0 { r * 0.95 } else { r * 0.6 };
1919                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
1920                    p(cx + r1*a.cos()), p(cy + r1*a.sin()), p(cx + r2*a.cos()), p(cy + r2*a.sin())).ok();
1921            }
1922            write!(
1923                s,
1924                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
1925                p(cx),
1926                p(cy),
1927                p(r * 0.18)
1928            )
1929            .ok();
1930            s
1931        },
1932        // ── 3D mesh & drawing ─────────────────────────────────────────────────
1933        Cat::Mesh => {
1934            format!(
1935                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.85"/>
1936                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="none" stroke="{c}" stroke-width="0.9" opacity="0.6"/>
1937                   <ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="none" stroke="{c}" stroke-width="0.9" opacity="0.6"/>
1938                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.9" opacity="0.6"/>"##,
1939                p(cx),
1940                p(cy),
1941                p(r * 0.9),
1942                p(cx),
1943                p(cy),
1944                p(r * 0.38),
1945                p(r * 0.9),
1946                p(cx),
1947                p(cy),
1948                p(r * 0.9),
1949                p(r * 0.38),
1950                p(cx - r * 0.9),
1951                p(cy),
1952                p(cx + r * 0.9),
1953                p(cy)
1954            )
1955        },
1956        Cat::Draw3D => {
1957            format!(
1958                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
1959                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.75"/>
1960                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.4" opacity="0.6"/>
1961                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1962                p(cx),
1963                p(cy + r * 0.3),
1964                p(cx),
1965                p(cy - r * 0.9),
1966                p(cx),
1967                p(cy + r * 0.3),
1968                p(cx + r * 0.9),
1969                p(cy + r * 0.7),
1970                p(cx),
1971                p(cy + r * 0.3),
1972                p(cx - r * 0.85),
1973                p(cy + r * 0.6),
1974                p(cx),
1975                p(cy + r * 0.3),
1976                p(r * 0.13)
1977            )
1978        },
1979        Cat::Draw2D => {
1980            format!(
1981                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.6" opacity="0.9" stroke-linecap="round"/>
1982                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>
1983                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
1984                p(cx - r * 0.8),
1985                p(cy + r * 0.8),
1986                p(cx + r * 0.8),
1987                p(cy - r * 0.8),
1988                p(cx - r * 0.8),
1989                p(cy + r * 0.8),
1990                p(r * 0.16),
1991                p(cx + r * 0.8),
1992                p(cy - r * 0.8),
1993                p(r * 0.16)
1994            )
1995        },
1996        // ── Math ──────────────────────────────────────────────────────────────
1997        Cat::Trig => {
1998            format!(
1999                r##"<polygon points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
2000                   <path d="M {},{} A {},{} 0 0,0 {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2001                p(cx - r * 0.8),
2002                p(cy + r * 0.7),
2003                p(cx + r * 0.8),
2004                p(cy + r * 0.7),
2005                p(cx - r * 0.8),
2006                p(cy - r * 0.7),
2007                p(cx - r * 0.35),
2008                p(cy + r * 0.7),
2009                p(r * 0.45),
2010                p(r * 0.45),
2011                p(cx - r * 0.8),
2012                p(cy + r * 0.38)
2013            )
2014        },
2015        Cat::MathFn => {
2016            format!(
2017                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.4"/>
2018                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.4"/>
2019                   <path d="M {},{} Q {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.9"/>"##,
2020                p(cx - r * 0.9),
2021                p(cy),
2022                p(cx + r * 0.9),
2023                p(cy),
2024                p(cx),
2025                p(cy - r * 0.9),
2026                p(cx),
2027                p(cy + r * 0.9),
2028                p(cx - r * 0.8),
2029                p(cy - r * 0.7),
2030                p(cx),
2031                p(cy + r * 1.1),
2032                p(cx + r * 0.8),
2033                p(cy - r * 0.7)
2034            )
2035        },
2036        Cat::Noise => {
2037            format!(
2038                r##"<polyline points="{},{} {},{} {},{} {},{} {},{} {},{} {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85" stroke-linejoin="round"/>"##,
2039                p(cx - r * 0.9),
2040                p(cy + r * 0.1),
2041                p(cx - r * 0.65),
2042                p(cy - r * 0.6),
2043                p(cx - r * 0.4),
2044                p(cy + r * 0.5),
2045                p(cx - r * 0.15),
2046                p(cy - r * 0.4),
2047                p(cx + r * 0.1),
2048                p(cy + r * 0.7),
2049                p(cx + r * 0.35),
2050                p(cy - r * 0.5),
2051                p(cx + r * 0.6),
2052                p(cy + r * 0.3),
2053                p(cx + r * 0.8),
2054                p(cy - r * 0.3),
2055                p(cx + r * 0.95),
2056                p(cy + r * 0.2)
2057            )
2058        },
2059        // ── Networking & AI ───────────────────────────────────────────────────
2060        Cat::Net => {
2061            let mut s = String::new();
2062            let nodes = [(0.0f32, -0.85f32), (0.8, 0.4), (-0.8, 0.4)];
2063            for &(nx, ny) in nodes.iter() {
2064                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>"##,
2065                    p(cx), p(cy), p(cx + nx*r), p(cy + ny*r)).ok();
2066            }
2067            write!(
2068                s,
2069                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
2070                p(cx),
2071                p(cy),
2072                p(r * 0.2)
2073            )
2074            .ok();
2075            for &(nx, ny) in nodes.iter() {
2076                write!(
2077                    s,
2078                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
2079                    p(cx + nx * r),
2080                    p(cy + ny * r),
2081                    p(r * 0.18)
2082                )
2083                .ok();
2084            }
2085            s
2086        },
2087        Cat::Neural => {
2088            let mut s = String::new();
2089            let l0 = [-0.6f32, 0.0, 0.6];
2090            let l1 = [-0.3f32, 0.3];
2091            let x0 = cx - r * 0.7;
2092            let x1 = cx;
2093            let x2 = cx + r * 0.7;
2094            for &a in l0.iter() {
2095                for &b in l1.iter() {
2096                    write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.6" opacity="0.4"/>"##,
2097                    p(x0), p(cy + a*r), p(x1), p(cy + b*r)).ok();
2098                }
2099            }
2100            for &b in l1.iter() {
2101                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.6" opacity="0.4"/>"##,
2102                    p(x1), p(cy + b*r), p(x2), p(cy)).ok();
2103            }
2104            for &a in l0.iter() {
2105                write!(
2106                    s,
2107                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2108                    p(x0),
2109                    p(cy + a * r),
2110                    p(r * 0.14)
2111                )
2112                .ok();
2113            }
2114            for &b in l1.iter() {
2115                write!(
2116                    s,
2117                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2118                    p(x1),
2119                    p(cy + b * r),
2120                    p(r * 0.14)
2121                )
2122                .ok();
2123            }
2124            write!(
2125                s,
2126                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2127                p(x2),
2128                p(cy),
2129                p(r * 0.14)
2130            )
2131            .ok();
2132            s
2133        },
2134        Cat::Behavior => {
2135            format!(
2136                r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2137                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2138                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
2139                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>
2140                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.8"/>"##,
2141                p(cx),
2142                p(cy - r * 0.6),
2143                p(cx - r * 0.7),
2144                p(cy + r * 0.6),
2145                p(cx),
2146                p(cy - r * 0.6),
2147                p(cx + r * 0.7),
2148                p(cy + r * 0.6),
2149                p(cx),
2150                p(cy - r * 0.6),
2151                p(r * 0.2),
2152                p(cx - r * 0.7),
2153                p(cy + r * 0.6),
2154                p(r * 0.18),
2155                p(cx + r * 0.7),
2156                p(cy + r * 0.6),
2157                p(r * 0.18)
2158            )
2159        },
2160        // ── UI / dialog / holograms ───────────────────────────────────────────
2161        Cat::Widget => {
2162            format!(
2163                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
2164                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>
2165                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="{c}" opacity="0.5"/>"##,
2166                p(cx - r * 0.85),
2167                p(cy - r * 0.75),
2168                p(r * 1.7),
2169                p(r * 1.5),
2170                p(cx - r * 0.85),
2171                p(cy - r * 0.3),
2172                p(cx + r * 0.85),
2173                p(cy - r * 0.3),
2174                p(cx - r * 0.5),
2175                p(cy + r * 0.1),
2176                p(r * 1.0),
2177                p(r * 0.45)
2178            )
2179        },
2180        Cat::Hud => {
2181            format!(
2182                r##"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.85"/>
2183                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.8" opacity="0.6"/>
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="1.3" opacity="0.9"/>
2186                   <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.9"/>"##,
2187                p(cx),
2188                p(cy),
2189                p(r * 0.9),
2190                p(cx - r * 0.9),
2191                p(cy),
2192                p(cx + r * 0.9),
2193                p(cy),
2194                p(cx),
2195                p(cy - r * 0.9),
2196                p(cx),
2197                p(cy + r * 0.9),
2198                p(cx),
2199                p(cy),
2200                p(cx + r * 0.64),
2201                p(cy - r * 0.64),
2202                p(cx + r * 0.4),
2203                p(cy - r * 0.4),
2204                p(r * 0.13)
2205            )
2206        },
2207        Cat::Dialog => {
2208            format!(
2209                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="4" fill="{c}" opacity="0.4" stroke="{c}" stroke-width="1.0"/>
2210                   <polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.4"/>
2211                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>
2212                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>
2213                   <circle cx="{}" cy="{}" r="{}" fill="#0b0b1a" opacity="0.85"/>"##,
2214                p(cx - r * 0.9),
2215                p(cy - r * 0.8),
2216                p(r * 1.8),
2217                p(r * 1.2),
2218                p(cx - r * 0.4),
2219                p(cy + r * 0.4),
2220                p(cx - r * 0.1),
2221                p(cy + r * 0.4),
2222                p(cx - r * 0.55),
2223                p(cy + r * 0.9),
2224                p(cx - r * 0.4),
2225                p(cy - r * 0.2),
2226                p(r * 0.1),
2227                p(cx),
2228                p(cy - r * 0.2),
2229                p(r * 0.1),
2230                p(cx + r * 0.4),
2231                p(cy - r * 0.2),
2232                p(r * 0.1)
2233            )
2234        },
2235        Cat::Holo => {
2236            let mut s = String::new();
2237            for i in 0..3 {
2238                let off = (i as f32 - 1.0) * r * 0.22;
2239                write!(s, r##"<polygon points="{},{} {},{} {},{}" fill="{c}" opacity="0.3" stroke="{c}" stroke-width="0.8" stroke-opacity="0.7"/>"##,
2240                    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();
2241            }
2242            s
2243        },
2244        // ── Text & vector ─────────────────────────────────────────────────────
2245        Cat::Str => {
2246            let mut s = String::new();
2247            for &dx in [-0.5f32, 0.3].iter() {
2248                write!(
2249                    s,
2250                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>
2251                             <circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.85"/>"##,
2252                    p(cx + dx * r),
2253                    p(cy - r * 0.4),
2254                    p(r * 0.16),
2255                    p(cx + dx * r + r * 0.3),
2256                    p(cy - r * 0.4),
2257                    p(r * 0.16)
2258                )
2259                .ok();
2260            }
2261            for j in 0..2 {
2262                let yy = cy + r * 0.3 + j as f32 * r * 0.45;
2263                let ww = if j == 0 { r * 1.6 } else { r * 1.0 };
2264                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.2" opacity="0.6"/>"##,
2265                    p(cx - r*0.8), p(yy), p(cx - r*0.8 + ww), p(yy)).ok();
2266            }
2267            s
2268        },
2269        Cat::Font => {
2270            format!(
2271                r##"<polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.5" opacity="0.9" stroke-linejoin="round"/>
2272                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.85"/>"##,
2273                p(cx - r * 0.7),
2274                p(cy + r * 0.8),
2275                p(cx),
2276                p(cy - r * 0.85),
2277                p(cx + r * 0.7),
2278                p(cy + r * 0.8),
2279                p(cx - r * 0.38),
2280                p(cy + r * 0.15),
2281                p(cx + r * 0.38),
2282                p(cy + r * 0.15)
2283            )
2284        },
2285        Cat::Vector => {
2286            format!(
2287                r##"<path d="M {},{} C {},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.4" opacity="0.9"/>
2288                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.7" opacity="0.5"/>
2289                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="0.7" opacity="0.5"/>
2290                   <rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.85"/>
2291                   <rect x="{}" y="{}" width="{}" height="{}" fill="{c}" opacity="0.85"/>
2292                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2293                   <circle cx="{}" cy="{}" r="{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2294                p(cx - r * 0.8),
2295                p(cy + r * 0.7),
2296                p(cx - r * 0.3),
2297                p(cy - r * 0.9),
2298                p(cx + r * 0.3),
2299                p(cy - r * 0.9),
2300                p(cx + r * 0.8),
2301                p(cy + r * 0.7),
2302                p(cx - r * 0.8),
2303                p(cy + r * 0.7),
2304                p(cx - r * 0.3),
2305                p(cy - r * 0.9),
2306                p(cx + r * 0.8),
2307                p(cy + r * 0.7),
2308                p(cx + r * 0.3),
2309                p(cy - r * 0.9),
2310                p(cx - r * 0.9),
2311                p(cy + r * 0.6),
2312                p(r * 0.2),
2313                p(r * 0.2),
2314                p(cx + r * 0.7),
2315                p(cy + r * 0.6),
2316                p(r * 0.2),
2317                p(r * 0.2),
2318                p(cx - r * 0.3),
2319                p(cy - r * 0.9),
2320                p(r * 0.14),
2321                p(cx + r * 0.3),
2322                p(cy - r * 0.9),
2323                p(r * 0.14)
2324            )
2325        },
2326        // ── Colour / shading / scene ──────────────────────────────────────────
2327        Cat::Color => {
2328            format!(
2329                r##"<circle cx="{}" cy="{}" r="{}" fill="#ff5e8a" opacity="0.5"/>
2330                   <circle cx="{}" cy="{}" r="{}" fill="#50fa7b" opacity="0.5"/>
2331                   <circle cx="{}" cy="{}" r="{}" fill="#6ab0f5" opacity="0.5"/>"##,
2332                p(cx),
2333                p(cy - r * 0.4),
2334                p(r * 0.6),
2335                p(cx - r * 0.45),
2336                p(cy + r * 0.35),
2337                p(r * 0.6),
2338                p(cx + r * 0.45),
2339                p(cy + r * 0.35),
2340                p(r * 0.6)
2341            )
2342        },
2343        Cat::Shade => {
2344            format!(
2345                r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="0.25" stroke="{c}" stroke-width="1.0"/>
2346                   <path d="M {},{} A {},{} 0 0,1 {},{} A {},{} 0 0,0 {},{} Z" fill="{c}" opacity="0.6"/>
2347                   <circle cx="{}" cy="{}" r="{}" fill="#ffffff" opacity="0.3"/>"##,
2348                p(cx),
2349                p(cy),
2350                p(r * 0.9),
2351                p(cx),
2352                p(cy - r * 0.9),
2353                p(r * 0.9),
2354                p(r * 0.9),
2355                p(cx),
2356                p(cy + r * 0.9),
2357                p(r * 0.45),
2358                p(r * 0.9),
2359                p(cx),
2360                p(cy - r * 0.9),
2361                p(cx + r * 0.35),
2362                p(cy - r * 0.35),
2363                p(r * 0.18)
2364            )
2365        },
2366        Cat::Window => {
2367            format!(
2368                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.85"/>
2369                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>
2370                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2371                p(cx - r * 0.85),
2372                p(cy - r * 0.85),
2373                p(r * 1.7),
2374                p(r * 1.7),
2375                p(cx),
2376                p(cy - r * 0.85),
2377                p(cx),
2378                p(cy + r * 0.85),
2379                p(cx - r * 0.85),
2380                p(cy),
2381                p(cx + r * 0.85),
2382                p(cy)
2383            )
2384        },
2385        // ── Input ─────────────────────────────────────────────────────────────
2386        Cat::Key => {
2387            format!(
2388                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="3" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.2"/>
2389                   <rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.7"/>"##,
2390                p(cx - r * 0.8),
2391                p(cy - r * 0.8),
2392                p(r * 1.6),
2393                p(r * 1.6),
2394                p(cx - r * 0.45),
2395                p(cy - r * 0.45),
2396                p(r * 0.9),
2397                p(r * 0.9)
2398            )
2399        },
2400        Cat::Mouse => {
2401            format!(
2402                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>
2403                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>
2404                   <rect x="{}" y="{}" width="{}" height="{}" rx="1" fill="{c}" opacity="0.85"/>"##,
2405                p(cx - r * 0.55),
2406                p(cy - r * 0.85),
2407                p(r * 1.1),
2408                p(r * 1.7),
2409                p(r * 0.55),
2410                p(cx),
2411                p(cy - r * 0.85),
2412                p(cx),
2413                p(cy - r * 0.1),
2414                p(cx - r * 0.08),
2415                p(cy - r * 0.7),
2416                p(r * 0.16),
2417                p(r * 0.4)
2418            )
2419        },
2420        // ── IO / system / timing ──────────────────────────────────────────────
2421        Cat::Print => {
2422            let mut s = format!(
2423                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="1.5" fill="none" stroke="{c}" stroke-width="1.2" opacity="0.85"/>"##,
2424                p(cx - r * 0.6),
2425                p(cy - r * 0.85),
2426                p(r * 1.2),
2427                p(r * 1.7)
2428            );
2429            for j in 0..4 {
2430                let yy = cy - r * 0.45 + j as f32 * r * 0.35;
2431                write!(s, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.0" opacity="0.6"/>"##,
2432                    p(cx - r*0.4), p(yy), p(cx + r*0.4), p(yy)).ok();
2433            }
2434            s
2435        },
2436        Cat::File => {
2437            format!(
2438                r##"<path d="M {},{} L {},{} L {},{} L {},{} L {},{} Z" fill="{c}" opacity="0.35" stroke="{c}" stroke-width="1.1"/>
2439                   <polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.0" opacity="0.8"/>"##,
2440                p(cx - r * 0.6),
2441                p(cy - r * 0.85),
2442                p(cx + r * 0.25),
2443                p(cy - r * 0.85),
2444                p(cx + r * 0.6),
2445                p(cy - r * 0.45),
2446                p(cx + r * 0.6),
2447                p(cy + r * 0.85),
2448                p(cx - r * 0.6),
2449                p(cy + r * 0.85),
2450                p(cx + r * 0.25),
2451                p(cy - r * 0.85),
2452                p(cx + r * 0.25),
2453                p(cy - r * 0.45),
2454                p(cx + r * 0.6),
2455                p(cy - r * 0.45)
2456            )
2457        },
2458        Cat::Sys => {
2459            format!(
2460                r##"<rect x="{}" y="{}" width="{}" height="{}" rx="2" fill="none" stroke="{c}" stroke-width="1.1" opacity="0.8"/>
2461                   <polyline points="{},{} {},{} {},{}" fill="none" stroke="{c}" stroke-width="1.3" opacity="0.9" stroke-linecap="round" stroke-linejoin="round"/>
2462                   <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.3" opacity="0.9" stroke-linecap="round"/>"##,
2463                p(cx - r * 0.9),
2464                p(cy - r * 0.7),
2465                p(r * 1.8),
2466                p(r * 1.4),
2467                p(cx - r * 0.45),
2468                p(cy - r * 0.2),
2469                p(cx - r * 0.1),
2470                p(cy + r * 0.15),
2471                p(cx - r * 0.45),
2472                p(cy + r * 0.5),
2473                p(cx + r * 0.1),
2474                p(cy + r * 0.5),
2475                p(cx + r * 0.5),
2476                p(cy + r * 0.5)
2477            )
2478        },
2479        Cat::Anim => {
2480            let mut s = String::new();
2481            for i in 0..6 {
2482                let a = (i as f32 * 60.0).to_radians();
2483                let rad = r * 0.7;
2484                let dotr = r * (0.08 + 0.16 * (i as f32 / 6.0));
2485                write!(
2486                    s,
2487                    r##"<circle cx="{}" cy="{}" r="{}" fill="{c}" opacity="{:.2}"/>"##,
2488                    p(cx + rad * a.cos()),
2489                    p(cy + rad * a.sin()),
2490                    p(dotr),
2491                    0.35 + i as f32 * 0.1
2492                )
2493                .ok();
2494            }
2495            s
2496        },
2497        Cat::Const => {
2498            format!(
2499                r#"<rect x="{}" y="{}" width="{}" height="{}" rx="2"
2500                           fill="none" stroke="{c}" stroke-width="1.2" opacity="0.7"/>
2501                       <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{c}" stroke-width="1.1" opacity="0.5"/>"#,
2502                p(cx - r * 0.7),
2503                p(cy - r * 0.55),
2504                p(r * 1.4),
2505                p(r * 1.1),
2506                p(cx - r * 0.4),
2507                p(cy),
2508                p(cx + r * 0.4),
2509                p(cy)
2510            )
2511        },
2512    }
2513}
2514
2515// ── Card rendering ────────────────────────────────────────────────────────────
2516
2517fn card_height(card: &FuncCard) -> f32 {
2518    let icon_rows = (card.calls.len() + ICONS_ROW - 1).max(1) / ICONS_ROW + 1;
2519    let base = CARD_PAD * 2.0          // top+bottom padding
2520        + 32.0                          // name header
2521        + 20.0; // params + stats row
2522    base + icon_rows as f32 * (ICON_SZ + ICON_GAP) + ICON_GAP
2523}
2524
2525fn render_card(card: &FuncCard, x: f32, y: f32) -> String {
2526    let h = card_height(card);
2527    let w = CARD_W;
2528    let r = CARD_ROUNDING;
2529
2530    // Dominant colour from most-common call category (by raw count)
2531    let dominant = card
2532        .calls
2533        .iter()
2534        .max_by_key(|c| c.count)
2535        .map(|c| c.cat.color())
2536        .unwrap_or(Cat::User.color());
2537
2538    let border_color = if card.is_entry { GOLD } else { dominant };
2539    let border_w = if card.is_entry { 2.5 } else { 1.2 };
2540    let glow_filter = if card.is_entry {
2541        r#" filter="url(#glow-gold)""#
2542    } else {
2543        ""
2544    };
2545
2546    let mut s = String::new();
2547
2548    // Card background
2549    write!(s, r#"<rect x="{}" y="{}" width="{w}" height="{h}" rx="{r}"
2550                      fill="{CARD_BG}" stroke="{border_color}" stroke-width="{border_w}"{glow_filter}/>
2551                 <line x1="{}" y1="{}" x2="{}" y2="{}"
2552                      stroke="{border_color}" stroke-width="3" opacity="0.6"/>
2553"#,
2554        p(x), p(y),
2555        p(x+r), p(y+h), p(x+r), p(y)   // left accent stripe
2556    ).ok();
2557
2558    // Function name
2559    let name_y = y + CARD_PAD + 18.0;
2560    let entry_badge = if card.is_entry {
2561        format!(
2562            r#" <text x="{}" y="{}" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.8">⬡ ENTRY</text>"#,
2563            p(x + w - CARD_PAD - 48.0),
2564            p(name_y)
2565        )
2566    } else {
2567        String::new()
2568    };
2569
2570    write!(
2571        s,
2572        r#"<text x="{}" y="{}" fill="{}" font-size="13" font-weight="bold">{}</text>{}"#,
2573        p(x + CARD_PAD + 10.0),
2574        p(name_y),
2575        if card.is_entry { GOLD } else { TEXT },
2576        xe(&card.name),
2577        entry_badge
2578    )
2579    .ok();
2580
2581    // Params + stats row
2582    let stats_y = name_y + 18.0;
2583    let params_str = if card.params.is_empty() {
2584        String::new()
2585    } else {
2586        format!("({})", card.params.join(", "))
2587    };
2588    let stats_str = {
2589        let mut parts = Vec::new();
2590        if card.vtex_count > 0 {
2591            parts.push(format!("{} vtex", card.vtex_count));
2592        }
2593        if card.audio_count > 0 {
2594            parts.push(format!("{} audio", card.audio_count));
2595        }
2596        if card.has_loop {
2597            parts.push("↺ loop".into());
2598        }
2599        parts.join("  ·  ")
2600    };
2601    write!(
2602        s,
2603        r#"<text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{}</text>
2604                 <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10" text-anchor="end">{}</text>
2605"#,
2606        p(x + CARD_PAD + 10.0),
2607        p(stats_y),
2608        xe(&params_str),
2609        p(x + w - CARD_PAD),
2610        p(stats_y),
2611        xe(&stats_str)
2612    )
2613    .ok();
2614
2615    // Divider
2616    let div_y = stats_y + 6.0;
2617    write!(
2618        s,
2619        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{CARD_BD}" stroke-width="1"/>"#,
2620        p(x + CARD_PAD),
2621        p(div_y),
2622        p(x + w - CARD_PAD),
2623        p(div_y)
2624    )
2625    .ok();
2626
2627    // Icons
2628    let icon_y0 = div_y + ICON_GAP + ICON_SZ / 2.0;
2629    let icon_x0 = x + CARD_PAD + ICON_SZ / 2.0 + 6.0;
2630    let ir = ICON_SZ / 2.0 * 0.82; // icon radius slightly smaller than half-cell
2631
2632    for (i, call) in card.calls.iter().enumerate() {
2633        let row = i / ICONS_ROW;
2634        let col = i % ICONS_ROW;
2635        let ix = icon_x0 + col as f32 * (ICON_SZ + ICON_GAP);
2636        let iy = icon_y0 + row as f32 * (ICON_SZ + ICON_GAP);
2637
2638        // Icon cell background
2639        write!(
2640            s,
2641            r#"<rect x="{}" y="{}" width="{ICON_SZ}" height="{ICON_SZ}" rx="3"
2642                          fill="{}" opacity="0.12"/>
2643"#,
2644            p(ix - ICON_SZ / 2.0),
2645            p(iy - ICON_SZ / 2.0),
2646            call.cat.color()
2647        )
2648        .ok();
2649
2650        // Icon shape
2651        s.push_str(&icon(call.cat, ix, iy, ir));
2652
2653        // Count badge (if > 1)
2654        if call.count > 1 {
2655            write!(s, r##"<rect x="{}" y="{}" width="13" height="10" rx="3" fill="{}" opacity="0.9"/>
2656                         <text x="{}" y="{}" fill="#0a0a1a" font-size="8" font-weight="bold" text-anchor="middle">{}</text>"##,
2657                p(ix + ir - 2.0), p(iy - ir - 1.0), call.cat.color(),
2658                p(ix + ir + 4.5), p(iy - ir + 7.0), call.count
2659            ).ok();
2660        }
2661    }
2662
2663    s
2664}
2665
2666// ── Legend, header, sidebar ───────────────────────────────────────────────────
2667
2668fn render_header(filename: &str, funcs: &[FuncCard]) -> String {
2669    let name = std::path::Path::new(filename)
2670        .file_name()
2671        .map(|n| n.to_string_lossy().into_owned())
2672        .unwrap_or_else(|| filename.to_string());
2673    let n_fns = funcs.len();
2674    let count = |pred: fn(Cat) -> bool| -> usize {
2675        funcs
2676            .iter()
2677            .flat_map(|f| &f.calls)
2678            .filter(|c| pred(c.cat))
2679            .map(|c| c.count)
2680            .sum()
2681    };
2682    let n_vtex = count(is_vtex);
2683    let n_audio = count(is_audio);
2684    let n_crypto = count(is_crypto);
2685    let n_physics = count(is_physics);
2686    let n_ai = count(is_ai);
2687    let n_calls: usize = funcs.iter().map(|f| f.calls.len()).sum();
2688
2689    // Domain stat chips — only show those actually present, so the header stays tight.
2690    let mut stats = vec![format!("{n_fns} fn"), format!("{n_calls} call types")];
2691    for (n, lbl) in [
2692        (n_vtex, "vtex"),
2693        (n_audio, "audio"),
2694        (n_crypto, "crypto"),
2695        (n_physics, "physics"),
2696        (n_ai, "ai"),
2697    ] {
2698        if n > 0 {
2699            stats.push(format!("{n} {lbl}"));
2700        }
2701    }
2702
2703    format!(
2704        r##"<rect x="0" y="0" width="{SVG_W}" height="{HEADER_H}" fill="#080814"/>
2705           <line x1="0" y1="{HEADER_H}" x2="{SVG_W}" y2="{HEADER_H}" stroke="#1a1a3a" stroke-width="1"/>
2706           <text x="20" y="22" fill="{GOLD}" font-size="9" font-weight="bold" opacity="0.6" letter-spacing="2">LING VISUALIZER</text>
2707           <text x="20" y="56" fill="{TEXT}" font-size="26" font-weight="bold">{}</text>
2708           <text x="{}" y="56" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"##,
2709        xe(&name),
2710        SVG_W - 20.0,
2711        stats.join("  ·  ")
2712    )
2713}
2714
2715fn render_legend(funcs: &[FuncCard]) -> String {
2716    // Collect present categories, then order them by coarse domain so related
2717    // icons cluster together; a faint tag introduces each domain group.
2718    let mut present: Vec<Cat> = Vec::new();
2719    let mut seen = HashSet::new();
2720    for f in funcs {
2721        for c in &f.calls {
2722            if seen.insert(c.cat) {
2723                present.push(c.cat);
2724            }
2725        }
2726    }
2727    present.sort_by(|a, b| a.domain().cmp(b.domain()).then(a.label().cmp(b.label())));
2728
2729    let mut s = format!(
2730        r##"<rect x="0" y="{HEADER_H}" width="{SVG_W}" height="{LEGEND_H}" fill="#0a0a18"/>
2731           <line x1="0" y1="{}" x2="{SVG_W}" y2="{}" stroke="#171730" stroke-width="1"/>"##,
2732        HEADER_H + LEGEND_H,
2733        HEADER_H + LEGEND_H
2734    );
2735
2736    let row_h = 21.0;
2737    let x_start = GRID_X + 8.0;
2738    let x_max = SVG_W - 24.0;
2739    let mut lx = x_start;
2740    let mut row = 0usize;
2741    let mut cur_domain = "";
2742    let row_y = |r: usize| HEADER_H + 17.0 + r as f32 * row_h;
2743
2744    for cat in present {
2745        let c = cat.color();
2746        let lbl = cat.label();
2747        let dom = cat.domain();
2748
2749        // Domain tag whenever the group changes (and a little extra lead space).
2750        if dom != cur_domain {
2751            let tag_w = dom.len() as f32 * 6.0 + 12.0;
2752            if lx > x_start && lx + tag_w > x_max {
2753                row += 1;
2754                lx = x_start;
2755            }
2756            if row > 1 {
2757                break;
2758            } // at most two rows in the band
2759            let ly = row_y(row);
2760            s.push_str(&format!(
2761                r##"<text x="{}" y="{}" fill="#3f3f66" font-size="8" font-weight="bold" letter-spacing="1">{}</text>"##,
2762                p(lx), p(ly + 3.0), dom.to_uppercase()
2763            ));
2764            lx += tag_w;
2765            cur_domain = dom;
2766        }
2767
2768        let item_w = 20.0 + lbl.len() as f32 * 6.2 + 12.0;
2769        if lx + item_w > x_max {
2770            row += 1;
2771            lx = x_start;
2772        }
2773        if row > 1 {
2774            break;
2775        }
2776        let ly = row_y(row);
2777        let icon_svg = icon(cat, lx + 7.0, ly, 6.0);
2778        s.push_str(&format!(
2779            r#"<rect x="{}" y="{}" width="14" height="14" rx="3" fill="{c}" opacity="0.12"/>
2780               {}
2781               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="10">{lbl}</text>"#,
2782            p(lx),
2783            p(ly - 7.0),
2784            icon_svg,
2785            p(lx + 18.0),
2786            p(ly + 4.0)
2787        ));
2788        lx += item_w;
2789    }
2790    s
2791}
2792
2793fn render_sidebar(globals: &[GlobalConst], total_h: f32) -> String {
2794    let h = total_h - CONTENT_Y;
2795    let mut s = format!(
2796        r##"<rect x="0" y="{CONTENT_Y}" width="{SIDEBAR_W}" height="{h}" fill="{SIDEBAR_BG}"/>
2797           <line x1="{SIDEBAR_W}" y1="{CONTENT_Y}" x2="{SIDEBAR_W}" y2="{total_h}" stroke="#1a1a3a" stroke-width="1"/>
2798           <text x="14" y="{}" fill="{TEXT_DIM}" font-size="9" font-weight="bold" letter-spacing="2">CONSTANTS</text>"##,
2799        CONTENT_Y + 20.0
2800    );
2801
2802    let mut gy = CONTENT_Y + 38.0;
2803    for g in globals {
2804        // Small colored diamond
2805        write!(
2806            s,
2807            r#"<rect x="{}" y="{}" width="8" height="8" rx="1" fill="{}" opacity="0.7"
2808                    transform="rotate(45,{},{})"/>
2809               <text x="{}" y="{}" fill="{}" font-size="12" font-weight="bold">{}</text>
2810               <text x="{}" y="{}" fill="{TEXT_DIM}" font-size="12" text-anchor="end">{}</text>"#,
2811            p(14.0),
2812            p(gy - 7.0),
2813            Cat::Star.color(),
2814            p(18.0),
2815            p(gy - 3.0),
2816            p(28.0),
2817            p(gy),
2818            Cat::Grid.color(),
2819            xe(&g.name),
2820            p(SIDEBAR_W - 10.0),
2821            p(gy),
2822            xe(&g.value)
2823        )
2824        .ok();
2825        gy += 22.0;
2826    }
2827    s
2828}
2829
2830// ── SVG defs (filters, patterns) ─────────────────────────────────────────────
2831
2832const DEFS: &str = r##"<defs>
2833  <filter id="glow-gold" x="-30%" y="-30%" width="160%" height="160%">
2834    <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur"/>
2835    <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
2836  </filter>
2837  <pattern id="grid-pat" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
2838    <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#ffffff" stroke-width="0.4"/>
2839  </pattern>
2840</defs>"##;
2841
2842// ── Public entry point ────────────────────────────────────────────────────────
2843
2844pub fn render(filename: &str, program: &Program) -> String {
2845    let doc = Document::build(filename, program);
2846
2847    // Layout: pack cards into COLS columns using shortest-column strategy
2848    let heights: Vec<f32> = doc.funcs.iter().map(|f| card_height(f)).collect();
2849    let mut col_y = vec![CONTENT_Y; COLS];
2850    let mut positions: Vec<(f32, f32)> = Vec::with_capacity(doc.funcs.len());
2851
2852    for &h in &heights {
2853        let (col, &cy) = col_y
2854            .iter()
2855            .enumerate()
2856            .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
2857            .unwrap();
2858        let cx = GRID_X + col as f32 * (CARD_W + CARD_GAP);
2859        positions.push((cx, cy));
2860        col_y[col] += h + CARD_GAP;
2861    }
2862
2863    let total_h = col_y.iter().cloned().fold(0.0f32, f32::max) + 40.0;
2864
2865    let mut svg = String::new();
2866    write!(
2867        svg,
2868        r#"<?xml version="1.0" encoding="UTF-8"?>
2869<svg xmlns="http://www.w3.org/2000/svg" width="{SVG_W}" height="{h}" viewBox="0 0 {SVG_W} {h}"
2870     style="font-family:'JetBrains Mono','Fira Code',monospace,sans-serif;background:{BG}">"#,
2871        h = total_h
2872    )
2873    .ok();
2874
2875    svg.push_str(DEFS);
2876
2877    // Background
2878    write!(svg, r#"<rect width="{SVG_W}" height="{total_h}" fill="{BG}"/>
2879                   <rect width="{SVG_W}" height="{total_h}" fill="url(#grid-pat)" opacity="0.05"/>"#,
2880        total_h = total_h).ok();
2881
2882    svg.push_str(&render_header(&doc.filename, &doc.funcs));
2883    svg.push_str(&render_legend(&doc.funcs));
2884    svg.push_str(&render_sidebar(&doc.globals, total_h));
2885
2886    for (card, &(cx, cy)) in doc.funcs.iter().zip(positions.iter()) {
2887        svg.push_str(&render_card(card, cx, cy));
2888    }
2889
2890    svg.push_str("\n</svg>");
2891    svg
2892}