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