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 pub by_affiliation: bool,
16 pub theme: Theme,
18 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), 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
48pub 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('&', "&")
59 .replace('<', "<")
60 .replace('>', ">")
61 .replace('"', """)
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
72pub 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 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 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
199pub 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 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 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
238pub 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
251fn 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 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 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 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 let show_legend = has_groups && !opts.by_affiliation;
321 if !show_legend {
322 legend.clear();
323 }
324
325 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let fy = height - 9.0;
789 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}