Skip to main content

contributor_graphs/
svg.rs

1use crate::model::{format_month_year, month_start_ts, thousands, Contributor, Release};
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    /// Tagged releases to mark with vertical lines (Wikipedia skin only).
19    pub releases: Vec<Release>,
20}
21
22impl Default for SvgOptions {
23    fn default() -> Self {
24        SvgOptions {
25            width: 1100.0,
26            title: String::new(),
27            subtitle: String::new(),
28            footer_left: String::new(),
29            footer_right: String::new(),
30            accent: "#2f6feb".into(),
31            by_affiliation: false,
32            theme: crate::theme::builtins().swap_remove(0), // light
33            releases: Vec::new(),
34        }
35    }
36}
37
38pub const GROUP_PALETTE: &[&str] = &[
39    "#4269d0", "#e7a13d", "#ff725c", "#6cc5b0", "#3ca951", "#ff8ab7", "#a463f2", "#97bbf5",
40    "#9c6b4e", "#9498a0", "#2f7f8f", "#c65b8a", "#d62728", "#17becf", "#7570b3", "#66a61e",
41    "#e6ab02", "#e7298a", "#1f9e89", "#b15928",
42];
43
44const ROW_H: f64 = 26.0;
45const BAR_H: f64 = 13.0;
46const AVATAR: f64 = 18.0;
47
48/// Bright, near-CSS-primary, distinct colours for the Wikipedia look — the
49/// saturated reds/greens/blues of the real Wikipedia EasyTimeline charts rather
50/// than a muted designer palette. In the flat skin this colours both the
51/// per-row "band member" bars and the affiliation groups.
52pub const BAND_PALETTE: &[&str] = &[
53    "#ff0000", "#0000ff", "#00a000", "#ff8c00", "#9400d3", "#009b9b", "#ff1493", "#76b900",
54    "#a0522d", "#1e90ff", "#e6a800", "#dc143c",
55];
56
57fn esc(s: &str) -> String {
58    s.replace('&', "&amp;")
59        .replace('<', "&lt;")
60        .replace('>', "&gt;")
61        .replace('"', "&quot;")
62}
63
64fn n(x: f64) -> String {
65    if (x - x.round()).abs() < 0.005 {
66        format!("{}", x.round() as i64)
67    } else {
68        format!("{x:.1}")
69    }
70}
71
72/// Rough text width estimate for a 400-weight sans-serif, in px.
73pub fn text_w(s: &str, size: f64) -> f64 {
74    s.chars()
75        .map(|c| match c {
76            'i' | 'l' | 'j' | 'f' | 't' | 'r' | '.' | ',' | '\'' | '|' | ' ' | '(' | ')' => 0.32,
77            'm' | 'w' | 'M' | 'W' | '@' => 0.88,
78            c if c.is_uppercase() => 0.70,
79            c if c.is_ascii_digit() => 0.56,
80            _ => 0.54,
81        })
82        .sum::<f64>()
83        * size
84}
85
86struct Ticks {
87    major: Vec<(i64, String)>,
88    minor: Vec<i64>,
89}
90
91fn year_ts(year: i32) -> i64 {
92    Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0)
93        .single()
94        .map(|d| d.timestamp())
95        .unwrap_or_default()
96}
97
98fn time_ticks(t0: i64, t1: i64) -> Ticks {
99    let span_days = (t1 - t0) as f64 / 86400.0;
100    let span_years = span_days / 365.25;
101    let y0 = Utc
102        .timestamp_opt(t0, 0)
103        .single()
104        .map(|d| d.year())
105        .unwrap_or(1970);
106    let y1 = Utc
107        .timestamp_opt(t1, 0)
108        .single()
109        .map(|d| d.year())
110        .unwrap_or(1970);
111
112    let mut major = Vec::new();
113    let mut minor = Vec::new();
114
115    if span_years > 2.2 {
116        let step = ((span_years / 11.0).ceil() as i32).max(1);
117        for y in (y0..=y1 + 1).filter(|y| (*y - y0) % step == 0) {
118            let ts = year_ts(y);
119            if ts >= t0 && ts <= t1 {
120                major.push((ts, y.to_string()));
121            }
122        }
123        // Minor ticks: months for short spans, quarters / years for longer.
124        let minor_months: i32 = if span_years <= 5.0 {
125            1
126        } else if span_years <= 11.0 {
127            3
128        } else {
129            12
130        };
131        let m_start = (y0 - 1970) * 12;
132        let m_end = (y1 + 1 - 1970) * 12 + 11;
133        for m in (m_start..=m_end).filter(|m| m % minor_months == 0) {
134            let ts = month_start_ts(m);
135            if ts >= t0 && ts <= t1 && !major.iter().any(|(t, _)| *t == ts) {
136                minor.push(ts);
137            }
138        }
139    } else {
140        // Short span: label months.
141        let step = if span_days > 500.0 {
142            3
143        } else if span_days > 240.0 {
144            2
145        } else {
146            1
147        };
148        let m_start = (y0 - 1970) * 12;
149        let m_end = (y1 + 1 - 1970) * 12 + 11;
150        let mut last_year_labelled = i32::MIN;
151        for m in m_start..=m_end {
152            let ts = month_start_ts(m);
153            if ts < t0 || ts > t1 {
154                continue;
155            }
156            if m % step == 0 {
157                let year = 1970 + m.div_euclid(12);
158                let mon = Utc
159                    .timestamp_opt(ts, 0)
160                    .single()
161                    .map(|d| d.format("%b").to_string())
162                    .unwrap_or_default();
163                let label = if year != last_year_labelled {
164                    last_year_labelled = year;
165                    format!("{mon} {year}")
166                } else {
167                    mon
168                };
169                major.push((ts, label));
170            } else {
171                minor.push(ts);
172            }
173        }
174    }
175    Ticks { major, minor }
176}
177
178fn initials(name: &str) -> String {
179    let mut it = name.split_whitespace().filter_map(|w| w.chars().next());
180    let a = it.next().unwrap_or('?');
181    match it.next_back() {
182        Some(b) => format!("{a}{b}"),
183        None => a.to_string(),
184    }
185    .to_uppercase()
186}
187
188fn hash_hue(name: &str) -> u32 {
189    let mut h: u32 = 2166136261;
190    for b in name.bytes() {
191        h ^= b as u32;
192        h = h.wrapping_mul(16777619);
193    }
194    h % 360
195}
196
197pub const OTHER_GROUP_COLOR: &str = "#9aa3ad";
198
199/// Assign `palette` colours to groups, cycling it so every group gets a colour
200/// rather than letting the long tail collapse into grey. The legend lists at
201/// most one group per distinct palette colour (in rank order), since beyond
202/// that colours repeat. Returns (group → colour, legend entries).
203pub fn group_colors(
204    rows: &[Contributor],
205    palette: &[&str],
206) -> (HashMap<String, String>, Vec<(String, String)>) {
207    let mut counts_map: HashMap<String, usize> = HashMap::new();
208    for c in rows {
209        if let Some(g) = &c.group {
210            *counts_map.entry(g.clone()).or_insert(0) += 1;
211        }
212        // Time-bounded affiliations contribute their other orgs too, so each
213        // earns a colour and a legend entry even if it's nobody's current org.
214        if let Some(mg) = &c.month_groups {
215            let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
216            for g in mg.iter().flatten() {
217                if Some(g.as_str()) != c.group.as_deref() && seen.insert(g.as_str()) {
218                    *counts_map.entry(g.clone()).or_insert(0) += 1;
219                }
220            }
221        }
222    }
223    // Rank by frequency, breaking ties by name so the palette is deterministic.
224    let mut counts: Vec<(String, usize)> = counts_map.into_iter().collect();
225    counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
226    let mut map = HashMap::new();
227    let mut legend = Vec::new();
228    for (i, (g, _)) in counts.iter().enumerate() {
229        let color = palette[i % palette.len()].to_string();
230        if i < palette.len() {
231            legend.push((g.clone(), color.clone()));
232        }
233        map.insert(g.clone(), color);
234    }
235    (map, legend)
236}
237
238/// Lightly smooth monthly counts so density shading reads as a heat
239/// gradient rather than a barcode.
240pub fn smooth_months(months: &[u32]) -> Vec<f64> {
241    let n = months.len();
242    (0..n)
243        .map(|i| {
244            let prev = if i > 0 { months[i - 1] } else { 0 } as f64;
245            let next = if i + 1 < n { months[i + 1] } else { 0 } as f64;
246            (2.0 * months[i] as f64 + prev + next) / 4.0
247        })
248        .collect()
249}
250
251/// A contributor's bars: each contiguous affiliation period (when they have
252/// time-bounded affiliations) or the single active span, as
253/// `(group, first active month, last active month)` indices into `months`.
254fn affiliation_runs(c: &Contributor) -> Vec<(Option<String>, usize, usize)> {
255    let nm = c.months.len();
256    let split = c.month_groups.is_some();
257    let group_at = |k: usize| -> Option<String> {
258        match &c.month_groups {
259            Some(mg) => mg.get(k).and_then(|o| o.clone()),
260            None => c.group.clone(),
261        }
262    };
263    let mut runs = Vec::new();
264    let mut i = 0;
265    while i < nm {
266        let g = group_at(i);
267        let mut j = if split { i } else { nm.saturating_sub(1) };
268        if split {
269            while j + 1 < nm && group_at(j + 1) == g {
270                j += 1;
271            }
272        }
273        let (mut start, mut end) = (None, 0usize);
274        for (k, &v) in c.months.iter().enumerate().take(j + 1).skip(i) {
275            if v > 0 {
276                start.get_or_insert(k);
277                end = k;
278            }
279        }
280        if let Some(a) = start {
281            runs.push((g, a, end));
282        }
283        i = j + 1;
284    }
285    runs
286}
287
288pub fn render_svg(rows: &[Contributor], opts: &SvgOptions) -> String {
289    let th = &opts.theme;
290    // The Wikipedia skin uses thicker bars with a slim white gap (≈¼ the bar
291    // height): bar = 0.8·row, gap = 0.2·row. Other themes keep the slimmer bar.
292    let bar_h = if th.flat {
293        (ROW_H * 0.8).round()
294    } else {
295        BAR_H
296    };
297    let width = opts.width.max(360.0);
298    // Colours are interpolated raw into SVG attributes; keep the user-supplied
299    // accent from breaking out of them.
300    let accent = esc(&opts.accent);
301
302    let t_first = rows.iter().map(|c| c.first).min().unwrap_or(0);
303    let t_last = rows.iter().map(|c| c.last).max().unwrap_or(1);
304    let pad_t = (((t_last - t_first) as f64) * 0.012) as i64 + 86400;
305    let (t0, t1) = (t_first - pad_t, t_last + pad_t);
306
307    // The flat (Wikipedia) skin colours groups from the bright primary palette;
308    // other themes use the muted multi-category palette.
309    let gpalette = if th.flat { BAND_PALETTE } else { GROUP_PALETTE };
310    let (gcolors, mut legend) = group_colors(rows, gpalette);
311    let has_groups = !gcolors.is_empty();
312    if has_groups
313        && rows.iter().any(|c| c.group.is_none())
314        && !legend.iter().any(|(g, _)| g == "Other")
315    {
316        legend.push(("Other".into(), OTHER_GROUP_COLOR.into()));
317    }
318    // In affiliation mode every row is its own group, so the legend would just
319    // duplicate the y-axis — suppress it.
320    let show_legend = has_groups && !opts.by_affiliation;
321    if !show_legend {
322        legend.clear();
323    }
324
325    // ---- layout ----
326    let name_size = 12.5;
327    let max_name_w = rows
328        .iter()
329        .map(|c| text_w(&c.name, name_size))
330        .fold(0.0_f64, f64::max);
331    let label_w = (max_name_w + AVATAR + 56.0).clamp(140.0, 380.0);
332    let count_col = 64.0;
333    let margin_r = 18.0;
334    let chart_x = label_w;
335    let chart_w = width - label_w - count_col - margin_r;
336
337    let header_h = 74.0;
338    // Lay out the legend with wrapping so every coloured group is shown.
339    let mut legend_pos: Vec<(f64, usize, String, String)> = Vec::new();
340    let legend_lines;
341    {
342        let mut x = chart_x;
343        let mut line = 0usize;
344        for (g, color) in &legend {
345            let w = 15.0 + text_w(g, 11.0) + 22.0;
346            if x + w > width - 20.0 && x > chart_x {
347                line += 1;
348                x = chart_x;
349            }
350            legend_pos.push((x, line, g.clone(), color.clone()));
351            x += w;
352        }
353        legend_lines = if legend_pos.is_empty() { 0 } else { line + 1 };
354    }
355    let legend_h = if show_legend {
356        legend_lines as f64 * 20.0 + 8.0
357    } else {
358        0.0
359    };
360    let chart_y = header_h + legend_h;
361    let chart_h = rows.len() as f64 * ROW_H;
362    let axis_h = 30.0;
363    let footer_h = 26.0;
364    let height = chart_y + chart_h + axis_h + footer_h;
365
366    let sx = |ts: i64| chart_x + (ts - t0) as f64 / (t1 - t0) as f64 * chart_w;
367
368    let mut s = String::with_capacity(256 * 1024);
369    let font = th.font_sans.as_str();
370    let _ = write!(
371        s,
372        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}">"#,
373        w = n(width),
374        h = n(height)
375    );
376    let _ = write!(
377        s,
378        r#"<style>a{{text-decoration:none}}.row text{{transition:fill .1s}}.row:hover .bar-base{{opacity:.28}}</style>"#
379    );
380    let _ = write!(
381        s,
382        r#"<rect width="{w}" height="{h}" fill="{bg}"/>"#,
383        w = n(width),
384        h = n(height),
385        bg = th.bg
386    );
387
388    // ---- header ----
389    // Wikipedia article headings are serif and normal-weight, not bold.
390    let title_weight = if th.flat { "400" } else { "700" };
391    let _ = write!(
392        s,
393        r#"<text x="28" y="36" font-size="19" font-weight="{}" fill="{}" font-family="{}" letter-spacing="-0.2">{}</text>"#,
394        title_weight,
395        th.text,
396        th.font_display,
397        esc(&opts.title)
398    );
399    let _ = write!(
400        s,
401        r#"<text x="28" y="56" font-size="12" fill="{}">{}</text>"#,
402        th.muted,
403        esc(&opts.subtitle)
404    );
405
406    // ---- group legend ----
407    for (x, line, g, color) in &legend_pos {
408        let y = header_h - 4.0 + *line as f64 * 20.0;
409        let _ = write!(
410            s,
411            r#"<rect x="{}" y="{}" width="10" height="10" rx="{rx}" fill="{color}"/>"#,
412            n(*x),
413            n(y),
414            rx = if th.flat { 0 } else { 3 }
415        );
416        let _ = write!(
417            s,
418            r#"<text x="{}" y="{}" font-size="11" fill="{}">{}</text>"#,
419            n(x + 15.0),
420            n(y + 9.0),
421            th.muted,
422            esc(g)
423        );
424    }
425
426    // ---- gridlines ----
427    let ticks = time_ticks(t0, t1);
428    let grid_top = chart_y - 6.0;
429    let grid_bot = chart_y + chart_h + 6.0;
430    // The Wikipedia skin drops the vertical background gridlines: release
431    // markers are the only verticals there.
432    if !th.flat {
433        for ts in &ticks.minor {
434            let x = sx(*ts);
435            let _ = write!(
436                s,
437                r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"/>"#,
438                x = n(x),
439                y1 = n(grid_top),
440                y2 = n(grid_bot),
441                c = th.grid_month
442            );
443        }
444    }
445    for (ts, label) in &ticks.major {
446        let x = sx(*ts);
447        if !th.flat {
448            let _ = write!(
449                s,
450                r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"/>"#,
451                x = n(x),
452                y1 = n(grid_top),
453                y2 = n(grid_bot),
454                c = th.grid_year
455            );
456        }
457        let _ = write!(
458            s,
459            r#"<text x="{x}" y="{y}" font-size="11" font-weight="600" fill="{c}" text-anchor="middle">{label}</text>"#,
460            x = n(x),
461            y = n(grid_bot + 17.0),
462            c = th.muted,
463            label = esc(label)
464        );
465    }
466    // Baseline under the chart. The Wikipedia skin draws solid black x- and
467    // y-axes that meet at the bottom-left corner.
468    let axis_c = if th.flat {
469        "#000"
470    } else {
471        th.grid_year.as_str()
472    };
473    let _ = write!(
474        s,
475        r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"#,
476        x1 = n(chart_x - if th.flat { 0.0 } else { 4.0 }),
477        x2 = n(chart_x + chart_w + 4.0),
478        y = n(grid_bot),
479        c = axis_c
480    );
481    if th.flat {
482        let _ = write!(
483            s,
484            r##"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="#000" stroke-width="1"/>"##,
485            x = n(chart_x),
486            y1 = n(grid_top),
487            y2 = n(grid_bot)
488        );
489    }
490    // Wikipedia skin: tick marks hanging below the axis baseline — longer at
491    // labelled (major) ticks, shorter at minor ones.
492    if th.flat {
493        for (ts, len) in ticks
494            .major
495            .iter()
496            .map(|(t, _)| (*t, 5.0))
497            .chain(ticks.minor.iter().map(|t| (*t, 3.0)))
498        {
499            let x = sx(ts);
500            let _ = write!(
501                s,
502                r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"/>"#,
503                x = n(x),
504                y1 = n(grid_bot),
505                y2 = n(grid_bot + len),
506                c = th.muted
507            );
508        }
509    }
510
511    // ---- defs: avatar clips ----
512    let _ = write!(s, "<defs>");
513    for (i, c) in rows.iter().enumerate() {
514        let y = chart_y + i as f64 * ROW_H;
515        if c.avatar.is_some() {
516            let cy = y + ROW_H / 2.0;
517            let cx = label_w - 16.0 - AVATAR / 2.0;
518            let _ = write!(
519                s,
520                r#"<clipPath id="av{i}"><circle cx="{cx}" cy="{cy}" r="{r}"/></clipPath>"#,
521                cx = n(cx),
522                cy = n(cy),
523                r = n(AVATAR / 2.0)
524            );
525        }
526    }
527    let _ = write!(s, "</defs>");
528
529    // ---- Wikipedia skin: grey row tracks + release markers ----
530    // Drawn before the rows so the release verticals sit over the grey tracks
531    // but behind the coloured bars the rows paint on top.
532    if th.flat {
533        for i in 0..rows.len() {
534            let by = chart_y + i as f64 * ROW_H + (ROW_H - bar_h) / 2.0;
535            let _ = write!(
536                s,
537                r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{c}"/>"#,
538                x = n(chart_x),
539                y = n(by),
540                w = n(chart_w),
541                h = n(bar_h),
542                c = th.track,
543            );
544        }
545        // A thin vertical at each tagged release, like the studio-album lines
546        // on Wikipedia band timelines.
547        for rel in &opts.releases {
548            if rel.ts < t0 || rel.ts > t1 {
549                continue;
550            }
551            let x = sx(rel.ts);
552            let _ = write!(
553                s,
554                r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"><title>{t}</title></line>"#,
555                x = n(x),
556                y1 = n(grid_top),
557                y2 = n(grid_bot),
558                c = th.release,
559                t = esc(&rel.name)
560            );
561        }
562    }
563
564    // ---- rows ----
565    for (i, c) in rows.iter().enumerate() {
566        let y = chart_y + i as f64 * ROW_H;
567        let cy = y + ROW_H / 2.0;
568        let color = c
569            .group
570            .as_ref()
571            .and_then(|g| gcolors.get(g).cloned())
572            .unwrap_or_else(|| {
573                if has_groups {
574                    OTHER_GROUP_COLOR.to_string()
575                } else if th.flat {
576                    // Wikipedia skin without groups: a distinct band per row.
577                    BAND_PALETTE[i % BAND_PALETTE.len()].to_string()
578                } else {
579                    accent.clone()
580                }
581            });
582
583        let _ = write!(s, r#"<g class="row">"#);
584        if opts.by_affiliation {
585            let people = if c.members == 1 { "person" } else { "people" };
586            let _ = write!(
587                s,
588                "<title>{} — {} {}, {} commits, {} – {}</title>",
589                esc(&c.name),
590                c.members,
591                people,
592                thousands(c.commits as u64),
593                esc(&format_month_year(c.first)),
594                esc(&format_month_year(c.last))
595            );
596        } else {
597            let _ = write!(
598                s,
599                "<title>{} — {} commits, {} – {}</title>",
600                esc(&c.name),
601                thousands(c.commits as u64),
602                esc(&format_month_year(c.first)),
603                esc(&format_month_year(c.last))
604            );
605        }
606
607        // Avatar, member-count badge (affiliation mode), or initials fallback.
608        let acx = label_w - 16.0 - AVATAR / 2.0;
609        if opts.by_affiliation {
610            let label = if c.members < 100 {
611                c.members.to_string()
612            } else {
613                "99+".into()
614            };
615            let fs = if c.members < 100 { 8.5 } else { 6.5 };
616            let _ = write!(
617                s,
618                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>"##,
619                cx = n(acx),
620                cy = n(cy),
621                r = n(AVATAR / 2.0),
622                ty = n(cy + 2.9),
623                fs = n(fs),
624                color = color,
625                label = esc(&label)
626            );
627        } else {
628            match &c.avatar {
629                Some(href) => {
630                    let _ = write!(
631                        s,
632                        r#"<image x="{x}" y="{y}" width="{d}" height="{d}" preserveAspectRatio="xMidYMid slice" clip-path="url(#av{i})" href="{href}"/>"#,
633                        x = n(acx - AVATAR / 2.0),
634                        y = n(cy - AVATAR / 2.0),
635                        d = n(AVATAR),
636                        href = esc(href)
637                    );
638                }
639                None => {
640                    let hue = hash_hue(&c.name);
641                    let _ = write!(
642                        s,
643                        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>"##,
644                        cx = n(acx),
645                        cy = n(cy),
646                        r = n(AVATAR / 2.0),
647                        ty = n(cy + 2.7),
648                        hue = hue,
649                        l = th.avatar_l(),
650                        init = esc(&initials(&c.name))
651                    );
652                }
653            }
654        }
655
656        // Name, right-aligned next to the avatar; linked when login known.
657        let name_x = label_w - 16.0 - AVATAR - 8.0;
658        let mut display = c.name.clone();
659        while text_w(&display, name_size) > label_w - 52.0 && display.chars().count() > 4 {
660            display = display.chars().take(display.chars().count() - 2).collect();
661            display.push('…');
662        }
663        let name_text = format!(
664            r#"<text x="{x}" y="{y}" font-size="{fs}" fill="{c}" text-anchor="end">{t}</text>"#,
665            x = n(name_x),
666            y = n(cy + 4.2),
667            fs = n(name_size),
668            c = th.text,
669            t = esc(&display)
670        );
671        match &c.url {
672            Some(u) => {
673                let _ = write!(
674                    s,
675                    r#"<a href="{}" target="_blank">{}</a>"#,
676                    esc(u),
677                    name_text
678                );
679            }
680            None => s.push_str(&name_text),
681        }
682
683        // Bar: a flat band per affiliation period (Wikipedia skin) or a faint
684        // base span with monthly activity-heat segments (default skin). A change
685        // of affiliation ends one bar and starts the next.
686        let by = y + (ROW_H - bar_h) / 2.0;
687        let runs = affiliation_runs(c);
688        let split = c.month_groups.is_some();
689        let smoothed = (!th.flat).then(|| smooth_months(&c.months));
690        let smax = smoothed.as_ref().map_or(1.0, |sm| {
691            sm.iter().fold(0.0_f64, |a, &b| a.max(b)).max(1e-9)
692        });
693        for (bk, (g, a, end)) in runs.iter().enumerate() {
694            let bar_color = if split {
695                g.as_deref()
696                    .and_then(|g| gcolors.get(g))
697                    .map_or(color.as_str(), String::as_str)
698            } else {
699                color.as_str()
700            };
701            let x0 = sx(month_start_ts(c.m0 + *a as i32));
702            let x1 = sx(month_start_ts(c.m0 + *end as i32 + 1));
703            if th.flat {
704                let w = (x1 - x0).max(6.0);
705                let x = if x1 - x0 < 6.0 {
706                    x0 + (x1 - x0) / 2.0 - 3.0
707                } else {
708                    x0
709                };
710                let _ = write!(
711                    s,
712                    r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{c}"/>"#,
713                    x = n(x),
714                    y = n(by),
715                    w = n(w),
716                    h = n(bar_h),
717                    c = bar_color,
718                );
719                continue;
720            }
721            let w = (x1 - x0).max(3.0);
722            let rx = (bar_h / 2.0).min(w / 2.0);
723            let _ = write!(
724                s,
725                r#"<rect class="bar-base" x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" fill="{c}" opacity="0.16"/>"#,
726                x = n(x0),
727                y = n(by),
728                w = n(w),
729                h = n(bar_h),
730                r = n(rx),
731                c = bar_color,
732            );
733            let sm = smoothed.as_ref().unwrap();
734            if w > 6.0 {
735                let _ = write!(
736                    s,
737                    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})">"#,
738                    x = n(x0),
739                    y = n(by),
740                    w = n(w),
741                    h = n(bar_h),
742                    r = n(rx),
743                );
744                for (k, &sval) in sm.iter().enumerate().take(end + 1).skip(*a) {
745                    if sval <= 0.0 {
746                        continue;
747                    }
748                    let m = c.m0 + k as i32;
749                    let mx0 = sx(month_start_ts(m));
750                    let mx1 = sx(month_start_ts(m + 1));
751                    let op = 0.28 + 0.72 * (sval / smax).sqrt();
752                    let _ = write!(
753                        s,
754                        r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{c}" opacity="{op:.2}"/>"#,
755                        x = n(mx0),
756                        y = n(by),
757                        w = n((mx1 - mx0).max(1.2)),
758                        h = n(bar_h),
759                        c = bar_color,
760                    );
761                }
762                let _ = write!(s, "</g>");
763            } else {
764                let _ = write!(
765                    s,
766                    r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{c}" opacity="0.9"/>"#,
767                    cx = n(x0 + w / 2.0),
768                    cy = n(cy),
769                    r = n(bar_h / 2.0 - 1.0),
770                    c = bar_color,
771                );
772            }
773        }
774
775        // Commit count in the right margin.
776        let _ = write!(
777            s,
778            r#"<text x="{x}" y="{y}" font-size="10.5" fill="{c}" text-anchor="end">{t}</text>"#,
779            x = n(width - margin_r),
780            y = n(cy + 3.8),
781            c = th.faint,
782            t = thousands(c.commits as u64)
783        );
784        let _ = write!(s, "</g>");
785    }
786
787    // ---- footer ----
788    let fy = height - 9.0;
789    // Help text explaining the release verticals, centred over the footer.
790    if th.flat && !opts.releases.is_empty() {
791        let label = "Vertical lines mark releases";
792        let tw = text_w(label, 10.0);
793        let start = chart_x + (chart_w - (10.0 + tw)) / 2.0;
794        let _ = write!(
795            s,
796            r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{c}" stroke-width="1"/>"#,
797            x = n(start),
798            y1 = n(fy - 7.0),
799            y2 = n(fy + 1.0),
800            c = th.release
801        );
802        let _ = write!(
803            s,
804            r#"<text x="{x}" y="{y}" font-size="10" fill="{c}">{t}</text>"#,
805            x = n(start + 10.0),
806            y = n(fy),
807            c = th.muted,
808            t = label
809        );
810    }
811    let _ = write!(
812        s,
813        r#"<text x="28" y="{y}" font-size="10" fill="{c}">{t}</text>"#,
814        y = n(fy),
815        c = th.faint,
816        t = esc(&opts.footer_left)
817    );
818    let _ = write!(
819        s,
820        r#"<text x="{x}" y="{y}" font-size="10" fill="{c}" text-anchor="end">{t}</text>"#,
821        x = n(width - margin_r),
822        y = n(fy),
823        c = th.faint,
824        t = esc(&opts.footer_right)
825    );
826
827    s.push_str("</svg>");
828    s
829}