Skip to main content

contributor_graphs/
svg.rs

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    /// Each row is a whole affiliation rather than one person.
14    pub by_affiliation: bool,
15    /// Render on a dark background.
16    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    /// HSL lightness (%) for the initials-fallback avatar discs.
51    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('<', "&lt;")
77        .replace('>', "&gt;")
78        .replace('"', "&quot;")
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
89/// Rough text width estimate for a 400-weight sans-serif, in px.
90pub 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        // Minor ticks: months for short spans, quarters / years for longer.
141        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        // Short span: label months.
158        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
217/// Assign palette colours to the most common groups; the long tail shares a
218/// neutral grey. Returns (group → colour, legend entries in rank order).
219pub 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
247/// Lightly smooth monthly counts so density shading reads as a heat
248/// gradient rather than a barcode.
249pub 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    // Colours are interpolated raw into SVG attributes; keep the user-supplied
264    // accent from breaking out of them.
265    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    // In affiliation mode every row is its own group, so the legend would just
281    // duplicate the y-axis — suppress it.
282    let show_legend = has_groups && !opts.by_affiliation;
283    if !show_legend {
284        legend.clear();
285    }
286
287    // ---- layout ----
288    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    // Lay out the legend with wrapping so every coloured group is shown.
301    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    // ---- header ----
351    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    // ---- group legend ----
365    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    // ---- gridlines ----
384    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    // Baseline under the chart.
418    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    // ---- defs: avatar clips ----
428    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        // Rounded clip for the activity bar.
443        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    // ---- rows ----
458    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        // Avatar, member-count badge (affiliation mode), or initials fallback.
498        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        // Name, right-aligned next to the avatar; linked when login known.
547        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        // Bar: faint base span + monthly activity segments.
574        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            // Single-burst contributor: solid pill.
611            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        // Commit count in the right margin.
621        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    // ---- footer ----
633    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}