1use crate::model::{format_month_year, month_start_ts, thousands, Contributor};
2use chrono::{Datelike, TimeZone, Utc};
3use std::collections::HashMap;
4use std::fmt::Write as _;
5
6pub struct SvgOptions {
7 pub width: f64,
8 pub title: String,
9 pub subtitle: String,
10 pub footer_left: String,
11 pub footer_right: String,
12 pub accent: String,
13 pub by_affiliation: bool,
15 pub dark: bool,
17}
18
19impl Default for SvgOptions {
20 fn default() -> Self {
21 SvgOptions {
22 width: 1100.0,
23 title: String::new(),
24 subtitle: String::new(),
25 footer_left: String::new(),
26 footer_right: String::new(),
27 accent: "#2f6feb".into(),
28 by_affiliation: false,
29 dark: false,
30 }
31 }
32}
33
34pub const GROUP_PALETTE: &[&str] = &[
35 "#4269d0", "#e7a13d", "#ff725c", "#6cc5b0", "#3ca951", "#ff8ab7", "#a463f2", "#97bbf5",
36 "#9c6b4e", "#9498a0", "#2f7f8f", "#c65b8a",
37];
38
39const ROW_H: f64 = 26.0;
40const BAR_H: f64 = 13.0;
41const AVATAR: f64 = 18.0;
42
43struct Theme {
44 text: &'static str,
45 muted: &'static str,
46 faint: &'static str,
47 grid_year: &'static str,
48 grid_month: &'static str,
49 bg: &'static str,
50 avatar_l: u32,
52}
53
54const LIGHT: Theme = Theme {
55 text: "#1c2530",
56 muted: "#5d6b7c",
57 faint: "#9aa5b1",
58 grid_year: "#e2e6ec",
59 grid_month: "#eef1f5",
60 bg: "#ffffff",
61 avatar_l: 62,
62};
63
64const DARK: Theme = Theme {
65 text: "#e6ebf2",
66 muted: "#9aa7b6",
67 faint: "#5f6c7b",
68 grid_year: "#232b35",
69 grid_month: "#1a212a",
70 bg: "#0d1117",
71 avatar_l: 48,
72};
73
74fn esc(s: &str) -> String {
75 s.replace('&', "&")
76 .replace('<', "<")
77 .replace('>', ">")
78 .replace('"', """)
79}
80
81fn n(x: f64) -> String {
82 if (x - x.round()).abs() < 0.005 {
83 format!("{}", x.round() as i64)
84 } else {
85 format!("{x:.1}")
86 }
87}
88
89pub fn text_w(s: &str, size: f64) -> f64 {
91 s.chars()
92 .map(|c| match c {
93 'i' | 'l' | 'j' | 'f' | 't' | 'r' | '.' | ',' | '\'' | '|' | ' ' | '(' | ')' => 0.32,
94 'm' | 'w' | 'M' | 'W' | '@' => 0.88,
95 c if c.is_uppercase() => 0.70,
96 c if c.is_ascii_digit() => 0.56,
97 _ => 0.54,
98 })
99 .sum::<f64>()
100 * size
101}
102
103struct Ticks {
104 major: Vec<(i64, String)>,
105 minor: Vec<i64>,
106}
107
108fn year_ts(year: i32) -> i64 {
109 Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0)
110 .single()
111 .map(|d| d.timestamp())
112 .unwrap_or_default()
113}
114
115fn time_ticks(t0: i64, t1: i64) -> Ticks {
116 let span_days = (t1 - t0) as f64 / 86400.0;
117 let span_years = span_days / 365.25;
118 let y0 = Utc
119 .timestamp_opt(t0, 0)
120 .single()
121 .map(|d| d.year())
122 .unwrap_or(1970);
123 let y1 = Utc
124 .timestamp_opt(t1, 0)
125 .single()
126 .map(|d| d.year())
127 .unwrap_or(1970);
128
129 let mut major = Vec::new();
130 let mut minor = Vec::new();
131
132 if span_years > 2.2 {
133 let step = ((span_years / 11.0).ceil() as i32).max(1);
134 for y in (y0..=y1 + 1).filter(|y| (*y - y0) % step == 0) {
135 let ts = year_ts(y);
136 if ts >= t0 && ts <= t1 {
137 major.push((ts, y.to_string()));
138 }
139 }
140 let minor_months: i32 = if span_years <= 5.0 {
142 1
143 } else if span_years <= 11.0 {
144 3
145 } else {
146 12
147 };
148 let m_start = (y0 - 1970) * 12;
149 let m_end = (y1 + 1 - 1970) * 12 + 11;
150 for m in (m_start..=m_end).filter(|m| m % minor_months == 0) {
151 let ts = month_start_ts(m);
152 if ts >= t0 && ts <= t1 && !major.iter().any(|(t, _)| *t == ts) {
153 minor.push(ts);
154 }
155 }
156 } else {
157 let step = if span_days > 500.0 {
159 3
160 } else if span_days > 240.0 {
161 2
162 } else {
163 1
164 };
165 let m_start = (y0 - 1970) * 12;
166 let m_end = (y1 + 1 - 1970) * 12 + 11;
167 let mut last_year_labelled = i32::MIN;
168 for m in m_start..=m_end {
169 let ts = month_start_ts(m);
170 if ts < t0 || ts > t1 {
171 continue;
172 }
173 if m % step == 0 {
174 let year = 1970 + m.div_euclid(12);
175 let mon = Utc
176 .timestamp_opt(ts, 0)
177 .single()
178 .map(|d| d.format("%b").to_string())
179 .unwrap_or_default();
180 let label = if year != last_year_labelled {
181 last_year_labelled = year;
182 format!("{mon} {year}")
183 } else {
184 mon
185 };
186 major.push((ts, label));
187 } else {
188 minor.push(ts);
189 }
190 }
191 }
192 Ticks { major, minor }
193}
194
195fn initials(name: &str) -> String {
196 let mut it = name.split_whitespace().filter_map(|w| w.chars().next());
197 let a = it.next().unwrap_or('?');
198 match it.next_back() {
199 Some(b) => format!("{a}{b}"),
200 None => a.to_string(),
201 }
202 .to_uppercase()
203}
204
205fn hash_hue(name: &str) -> u32 {
206 let mut h: u32 = 2166136261;
207 for b in name.bytes() {
208 h ^= b as u32;
209 h = h.wrapping_mul(16777619);
210 }
211 h % 360
212}
213
214pub const OTHER_GROUP_COLOR: &str = "#9aa3ad";
215const MAX_GROUP_COLORS: usize = 10;
216
217pub fn group_colors(rows: &[Contributor]) -> (HashMap<String, String>, Vec<(String, String)>) {
220 let mut counts: Vec<(String, usize)> = Vec::new();
221 for c in rows {
222 if let Some(g) = &c.group {
223 match counts.iter_mut().find(|(name, _)| name == g) {
224 Some((_, n)) => *n += 1,
225 None => counts.push((g.clone(), 1)),
226 }
227 }
228 }
229 counts.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
230 let mut map = HashMap::new();
231 let mut legend = Vec::new();
232 for (i, (g, _)) in counts.iter().enumerate() {
233 if i < MAX_GROUP_COLORS {
234 let color = GROUP_PALETTE[i % GROUP_PALETTE.len()].to_string();
235 legend.push((g.clone(), color.clone()));
236 map.insert(g.clone(), color);
237 } else {
238 map.insert(g.clone(), OTHER_GROUP_COLOR.to_string());
239 }
240 }
241 if counts.len() > MAX_GROUP_COLORS {
242 legend.push(("Other".into(), OTHER_GROUP_COLOR.into()));
243 }
244 (map, legend)
245}
246
247pub fn smooth_months(months: &[u32]) -> Vec<f64> {
250 let n = months.len();
251 (0..n)
252 .map(|i| {
253 let prev = if i > 0 { months[i - 1] } else { 0 } as f64;
254 let next = if i + 1 < n { months[i + 1] } else { 0 } as f64;
255 (2.0 * months[i] as f64 + prev + next) / 4.0
256 })
257 .collect()
258}
259
260pub fn render_svg(rows: &[Contributor], opts: &SvgOptions) -> String {
261 let th = if opts.dark { &DARK } else { &LIGHT };
262 let width = opts.width.max(360.0);
263 let accent = esc(&opts.accent);
266
267 let t_first = rows.iter().map(|c| c.first).min().unwrap_or(0);
268 let t_last = rows.iter().map(|c| c.last).max().unwrap_or(1);
269 let pad_t = (((t_last - t_first) as f64) * 0.012) as i64 + 86400;
270 let (t0, t1) = (t_first - pad_t, t_last + pad_t);
271
272 let (gcolors, mut legend) = group_colors(rows);
273 let has_groups = !gcolors.is_empty();
274 if has_groups
275 && rows.iter().any(|c| c.group.is_none())
276 && !legend.iter().any(|(g, _)| g == "Other")
277 {
278 legend.push(("Other".into(), OTHER_GROUP_COLOR.into()));
279 }
280 let show_legend = has_groups && !opts.by_affiliation;
283 if !show_legend {
284 legend.clear();
285 }
286
287 let name_size = 12.5;
289 let max_name_w = rows
290 .iter()
291 .map(|c| text_w(&c.name, name_size))
292 .fold(0.0_f64, f64::max);
293 let label_w = (max_name_w + AVATAR + 56.0).clamp(140.0, 380.0);
294 let count_col = 64.0;
295 let margin_r = 18.0;
296 let chart_x = label_w;
297 let chart_w = width - label_w - count_col - margin_r;
298
299 let header_h = 74.0;
300 let mut legend_pos: Vec<(f64, usize, String, String)> = Vec::new();
302 let legend_lines;
303 {
304 let mut x = chart_x;
305 let mut line = 0usize;
306 for (g, color) in &legend {
307 let w = 15.0 + text_w(g, 11.0) + 22.0;
308 if x + w > width - 20.0 && x > chart_x {
309 line += 1;
310 x = chart_x;
311 }
312 legend_pos.push((x, line, g.clone(), color.clone()));
313 x += w;
314 }
315 legend_lines = if legend_pos.is_empty() { 0 } else { line + 1 };
316 }
317 let legend_h = if show_legend {
318 legend_lines as f64 * 20.0 + 8.0
319 } else {
320 0.0
321 };
322 let chart_y = header_h + legend_h;
323 let chart_h = rows.len() as f64 * ROW_H;
324 let axis_h = 30.0;
325 let footer_h = 26.0;
326 let height = chart_y + chart_h + axis_h + footer_h;
327
328 let sx = |ts: i64| chart_x + (ts - t0) as f64 / (t1 - t0) as f64 * chart_w;
329
330 let mut s = String::with_capacity(256 * 1024);
331 let font = "-apple-system, 'Segoe UI', 'Inter', 'Helvetica Neue', Arial, sans-serif";
332 let _ = write!(
333 s,
334 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{w}" height="{h}" viewBox="0 0 {w} {h}" font-family="{font}">"#,
335 w = n(width),
336 h = n(height)
337 );
338 let _ = write!(
339 s,
340 r#"<style>a{{text-decoration:none}}.row text{{transition:fill .1s}}.row:hover .bar-base{{opacity:.28}}</style>"#
341 );
342 let _ = write!(
343 s,
344 r#"<rect width="{w}" height="{h}" fill="{bg}"/>"#,
345 w = n(width),
346 h = n(height),
347 bg = th.bg
348 );
349
350 let _ = write!(
352 s,
353 r#"<text x="28" y="36" font-size="19" font-weight="700" fill="{}" letter-spacing="-0.2">{}</text>"#,
354 th.text,
355 esc(&opts.title)
356 );
357 let _ = write!(
358 s,
359 r#"<text x="28" y="56" font-size="12" fill="{}">{}</text>"#,
360 th.muted,
361 esc(&opts.subtitle)
362 );
363
364 for (x, line, g, color) in &legend_pos {
366 let y = header_h - 4.0 + *line as f64 * 20.0;
367 let _ = write!(
368 s,
369 r#"<rect x="{}" y="{}" width="10" height="10" rx="3" fill="{color}"/>"#,
370 n(*x),
371 n(y)
372 );
373 let _ = write!(
374 s,
375 r#"<text x="{}" y="{}" font-size="11" fill="{}">{}</text>"#,
376 n(x + 15.0),
377 n(y + 9.0),
378 th.muted,
379 esc(g)
380 );
381 }
382
383 let ticks = time_ticks(t0, t1);
385 let grid_top = chart_y - 6.0;
386 let grid_bot = chart_y + chart_h + 6.0;
387 for ts in &ticks.minor {
388 let x = sx(*ts);
389 let _ = write!(
390 s,
391 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"/>"#,
392 x = n(x),
393 y1 = n(grid_top),
394 y2 = n(grid_bot),
395 c = th.grid_month
396 );
397 }
398 for (ts, label) in &ticks.major {
399 let x = sx(*ts);
400 let _ = write!(
401 s,
402 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"/>"#,
403 x = n(x),
404 y1 = n(grid_top),
405 y2 = n(grid_bot),
406 c = th.grid_year
407 );
408 let _ = write!(
409 s,
410 r#"<text x="{x}" y="{y}" font-size="11" font-weight="600" fill="{c}" text-anchor="middle">{label}</text>"#,
411 x = n(x),
412 y = n(grid_bot + 17.0),
413 c = th.muted,
414 label = esc(label)
415 );
416 }
417 let _ = write!(
419 s,
420 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"#,
421 x1 = n(chart_x - 4.0),
422 x2 = n(chart_x + chart_w + 4.0),
423 y = n(grid_bot),
424 c = th.grid_year
425 );
426
427 let _ = write!(s, "<defs>");
429 for (i, c) in rows.iter().enumerate() {
430 let y = chart_y + i as f64 * ROW_H;
431 if c.avatar.is_some() {
432 let cy = y + ROW_H / 2.0;
433 let cx = label_w - 16.0 - AVATAR / 2.0;
434 let _ = write!(
435 s,
436 r#"<clipPath id="av{i}"><circle cx="{cx}" cy="{cy}" r="{r}"/></clipPath>"#,
437 cx = n(cx),
438 cy = n(cy),
439 r = n(AVATAR / 2.0)
440 );
441 }
442 let bx = sx(c.first);
444 let bw = (sx(c.last) - bx).max(3.0);
445 let _ = write!(
446 s,
447 r#"<clipPath id="bar{i}"><rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}"/></clipPath>"#,
448 x = n(bx),
449 y = n(y + (ROW_H - BAR_H) / 2.0),
450 w = n(bw),
451 h = n(BAR_H),
452 r = n((BAR_H / 2.0).min(bw / 2.0))
453 );
454 }
455 let _ = write!(s, "</defs>");
456
457 for (i, c) in rows.iter().enumerate() {
459 let y = chart_y + i as f64 * ROW_H;
460 let cy = y + ROW_H / 2.0;
461 let color = c
462 .group
463 .as_ref()
464 .and_then(|g| gcolors.get(g).cloned())
465 .unwrap_or_else(|| {
466 if has_groups {
467 OTHER_GROUP_COLOR.to_string()
468 } else {
469 accent.clone()
470 }
471 });
472
473 let _ = write!(s, r#"<g class="row">"#);
474 if opts.by_affiliation {
475 let people = if c.members == 1 { "person" } else { "people" };
476 let _ = write!(
477 s,
478 "<title>{} — {} {}, {} commits, {} – {}</title>",
479 esc(&c.name),
480 c.members,
481 people,
482 thousands(c.commits as u64),
483 esc(&format_month_year(c.first)),
484 esc(&format_month_year(c.last))
485 );
486 } else {
487 let _ = write!(
488 s,
489 "<title>{} — {} commits, {} – {}</title>",
490 esc(&c.name),
491 thousands(c.commits as u64),
492 esc(&format_month_year(c.first)),
493 esc(&format_month_year(c.last))
494 );
495 }
496
497 let acx = label_w - 16.0 - AVATAR / 2.0;
499 if opts.by_affiliation {
500 let label = if c.members < 100 {
501 c.members.to_string()
502 } else {
503 "99+".into()
504 };
505 let fs = if c.members < 100 { 8.5 } else { 6.5 };
506 let _ = write!(
507 s,
508 r##"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{color}"/><text x="{cx}" y="{ty}" font-size="{fs}" font-weight="700" fill="#fff" text-anchor="middle">{label}</text>"##,
509 cx = n(acx),
510 cy = n(cy),
511 r = n(AVATAR / 2.0),
512 ty = n(cy + 2.9),
513 fs = n(fs),
514 color = color,
515 label = esc(&label)
516 );
517 } else {
518 match &c.avatar {
519 Some(href) => {
520 let _ = write!(
521 s,
522 r#"<image x="{x}" y="{y}" width="{d}" height="{d}" preserveAspectRatio="xMidYMid slice" clip-path="url(#av{i})" href="{href}"/>"#,
523 x = n(acx - AVATAR / 2.0),
524 y = n(cy - AVATAR / 2.0),
525 d = n(AVATAR),
526 href = esc(href)
527 );
528 }
529 None => {
530 let hue = hash_hue(&c.name);
531 let _ = write!(
532 s,
533 r##"<circle cx="{cx}" cy="{cy}" r="{r}" fill="hsl({hue},42%,{l}%)"/><text x="{cx}" y="{ty}" font-size="7.5" font-weight="700" fill="#fff" text-anchor="middle">{init}</text>"##,
534 cx = n(acx),
535 cy = n(cy),
536 r = n(AVATAR / 2.0),
537 ty = n(cy + 2.7),
538 hue = hue,
539 l = th.avatar_l,
540 init = esc(&initials(&c.name))
541 );
542 }
543 }
544 }
545
546 let name_x = label_w - 16.0 - AVATAR - 8.0;
548 let mut display = c.name.clone();
549 while text_w(&display, name_size) > label_w - 52.0 && display.chars().count() > 4 {
550 display = display.chars().take(display.chars().count() - 2).collect();
551 display.push('…');
552 }
553 let name_text = format!(
554 r#"<text x="{x}" y="{y}" font-size="{fs}" fill="{c}" text-anchor="end">{t}</text>"#,
555 x = n(name_x),
556 y = n(cy + 4.2),
557 fs = n(name_size),
558 c = th.text,
559 t = esc(&display)
560 );
561 match &c.url {
562 Some(u) => {
563 let _ = write!(
564 s,
565 r#"<a href="{}" target="_blank">{}</a>"#,
566 esc(u),
567 name_text
568 );
569 }
570 None => s.push_str(&name_text),
571 }
572
573 let bx = sx(c.first);
575 let bw = (sx(c.last) - bx).max(3.0);
576 let by = y + (ROW_H - BAR_H) / 2.0;
577 let _ = write!(
578 s,
579 r#"<rect class="bar-base" x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" fill="{color}" opacity="0.16"/>"#,
580 x = n(bx),
581 y = n(by),
582 w = n(bw),
583 h = n(BAR_H),
584 r = n((BAR_H / 2.0).min(bw / 2.0))
585 );
586 let smoothed = smooth_months(&c.months);
587 let smax = smoothed.iter().fold(0.0_f64, |a, &b| a.max(b)).max(1e-9);
588 if bw > 6.0 {
589 let _ = write!(s, r#"<g clip-path="url(#bar{i})">"#);
590 for (mi, &sval) in smoothed.iter().enumerate() {
591 if sval <= 0.0 {
592 continue;
593 }
594 let m = c.m0 + mi as i32;
595 let mx0 = sx(month_start_ts(m).max(c.first));
596 let mx1 = sx(month_start_ts(m + 1).min(c.last));
597 let w = (mx1 - mx0).max(1.2);
598 let op = 0.28 + 0.72 * (sval / smax).sqrt();
599 let _ = write!(
600 s,
601 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{color}" opacity="{op:.2}"/>"#,
602 x = n(mx0),
603 y = n(by),
604 w = n(w),
605 h = n(BAR_H),
606 );
607 }
608 let _ = write!(s, "</g>");
609 } else {
610 let _ = write!(
612 s,
613 r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{color}" opacity="0.9"/>"#,
614 cx = n(bx + bw / 2.0),
615 cy = n(cy),
616 r = n(BAR_H / 2.0 - 1.0)
617 );
618 }
619
620 let _ = write!(
622 s,
623 r#"<text x="{x}" y="{y}" font-size="10.5" fill="{c}" text-anchor="end">{t}</text>"#,
624 x = n(width - margin_r),
625 y = n(cy + 3.8),
626 c = th.faint,
627 t = thousands(c.commits as u64)
628 );
629 let _ = write!(s, "</g>");
630 }
631
632 let fy = height - 9.0;
634 let _ = write!(
635 s,
636 r#"<text x="28" y="{y}" font-size="10" fill="{c}">{t}</text>"#,
637 y = n(fy),
638 c = th.faint,
639 t = esc(&opts.footer_left)
640 );
641 let _ = write!(
642 s,
643 r#"<text x="{x}" y="{y}" font-size="10" fill="{c}" text-anchor="end">{t}</text>"#,
644 x = n(width - margin_r),
645 y = n(fy),
646 c = th.faint,
647 t = esc(&opts.footer_right)
648 );
649
650 s.push_str("</svg>");
651 s
652}