Skip to main content

contributor_graphs/
svg.rs

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    /// Each row is a whole affiliation rather than one person.
15    pub by_affiliation: bool,
16    /// The theme to render in.
17    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), // light
31        }
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
44/// Flat, saturated, distinct per-row colours for the Wikipedia "band members"
45/// look.
46pub 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('<', "&lt;")
54        .replace('>', "&gt;")
55        .replace('"', "&quot;")
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
66/// Rough text width estimate for a 400-weight sans-serif, in px.
67pub 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        // Minor ticks: months for short spans, quarters / years for longer.
118        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        // Short span: label months.
135        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
194/// Assign palette colours to the most common groups; the long tail shares a
195/// neutral grey. Returns (group → colour, legend entries in rank order).
196pub 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        // Time-bounded affiliations contribute their other orgs too, so each
203        // earns a colour and a legend entry even if it's nobody's current org.
204        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    // Rank by frequency, breaking ties by name so the palette is deterministic.
214    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
233/// Lightly smooth monthly counts so density shading reads as a heat
234/// gradient rather than a barcode.
235pub 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
246/// A contributor's bars: each contiguous affiliation period (when they have
247/// time-bounded affiliations) or the single active span, as
248/// `(group, first active month, last active month)` indices into `months`.
249fn 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    // Colours are interpolated raw into SVG attributes; keep the user-supplied
287    // accent from breaking out of them.
288    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    // In affiliation mode every row is its own group, so the legend would just
304    // duplicate the y-axis — suppress it.
305    let show_legend = has_groups && !opts.by_affiliation;
306    if !show_legend {
307        legend.clear();
308    }
309
310    // ---- layout ----
311    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    // Lay out the legend with wrapping so every coloured group is shown.
324    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    // ---- header ----
374    // Wikipedia article headings are serif and normal-weight, not bold.
375    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    // ---- group legend ----
392    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    // ---- gridlines ----
411    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    // Baseline under the chart.
445    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    // ---- defs: avatar clips ----
455    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    // ---- rows ----
473    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                    // Wikipedia skin without groups: a distinct band per row.
485                    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        // Avatar, member-count badge (affiliation mode), or initials fallback.
516        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        // Name, right-aligned next to the avatar; linked when login known.
565        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        // Bar: a flat band per affiliation period (Wikipedia skin) or a faint
592        // base span with monthly activity-heat segments (default skin). A change
593        // of affiliation ends one bar and starts the next.
594        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        // Commit count in the right margin.
684        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    // ---- footer ----
696    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}