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