diff --git a/src/og.rs b/src/og.rs index 933314e..2f867cc 100644 --- a/src/og.rs +++ b/src/og.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use resvg::{tiny_skia, usvg}; +use crate::burn::BurnSeries; use crate::land_gap::LandGap; use crate::project_timelines::ProjectTimelines; use crate::stats::{DailyPoint, HourlySeries, SiteData}; @@ -279,14 +280,33 @@ fn wheel_zone(name: Option<&str>) -> chrono_tz::Tz { .unwrap_or(chrono_tz::UTC) } -/// Commits per hour of the day in `zone` over the last `days` UTC days, as -/// the page bins them; each hour converts on its own date, so daylight -/// saving lands right. +/// Commits per hour of the day in `zone` over the last `days` UTC days. fn hour_bins( hourly: &HourlySeries, generated_at: &str, days: i64, zone: chrono_tz::Tz, +) -> [u64; 24] { + let rows = if hourly.projects.is_empty() { + &hourly.rows + } else { + &hourly.projects + }; + let rows = rows.iter().filter_map(|row| { + let (&hour, counts) = row.split_first()?; + Some((hour, counts.iter().filter(|n| **n > 0).sum::() as u64)) + }); + hours_of_day(rows, generated_at, days, zone) +} + +/// `(hours since the epoch, value)` rows summed per hour of the day in +/// `zone` over the last `days` UTC days, as the page bins them; each hour +/// converts on its own date, so daylight saving lands right. +fn hours_of_day( + rows: impl Iterator, + generated_at: &str, + days: i64, + zone: chrono_tz::Tz, ) -> [u64; 24] { let mut bins = [0u64; 24]; let Ok(generated) = chrono::DateTime::parse_from_rfc3339(generated_at) else { @@ -294,15 +314,7 @@ fn hour_bins( }; let midnight = generated.timestamp().div_euclid(86_400) * 86_400; let start = (midnight - (days - 1) * 86_400) / 3600; - let rows = if hourly.projects.is_empty() { - &hourly.rows - } else { - &hourly.projects - }; - for row in rows { - let Some((&hour, counts)) = row.split_first() else { - continue; - }; + for (hour, value) in rows { let Some(at) = chrono::DateTime::from_timestamp(hour * 3600, 0) else { continue; }; @@ -310,7 +322,7 @@ fn hour_bins( continue; } let local = chrono::Timelike::hour(&at.with_timezone(&zone)) as usize; - bins[local] += counts.iter().filter(|n| **n > 0).sum::() as u64; + bins[local] += value; } bins } @@ -385,6 +397,36 @@ fn wheel(bins: &[u64; 24], cx: f64, cy: f64, r0: f64, r: f64, labels: Option String { + let max = bins.iter().copied().max().unwrap_or(0) as f64; + if max == 0.0 { + return String::new(); + } + let d: Vec = bins + .iter() + .enumerate() + .map(|(hour, value)| { + let a = + (hour as f64 + 0.5) / 24.0 * std::f64::consts::TAU - std::f64::consts::FRAC_PI_2; + let radius = r0 + (r - r0) * *value as f64 / max; + let step = if hour == 0 { "M" } else { "L" }; + format!( + "{step} {:.1} {:.1}", + cx + radius * a.cos(), + cy + radius * a.sin() + ) + }) + .collect(); + let d = d.join(" "); + format!( + r##" +"## + ) +} + /// How `scatter` draws its dots. struct ScatterStyle { /// At most this many, an even sample along the gap order. @@ -600,6 +642,7 @@ pub const DASHBOARD: Layout = Layout { &data.hourly, &data.land_gap, &data.project_timelines, + data.burn.as_ref(), &data.summary.generated_at, data.summary.timezone.as_deref(), ) @@ -610,11 +653,16 @@ fn dashboard_card( hourly: &HourlySeries, gap: &LandGap, timelines: &ProjectTimelines, + burn: Option<&BurnSeries>, generated_at: &str, timezone: Option<&str>, ) -> String { let days = &daily[daily.len().saturating_sub(90)..]; - let bins = hour_bins(hourly, generated_at, 30, wheel_zone(timezone)); + let zone = wheel_zone(timezone); + let bins = hour_bins(hourly, generated_at, 30, zone); + let burn_rows = burn.into_iter().flat_map(|burn| &burn.hours); + let burn_rows = burn_rows.map(|&[hour, burn]| (hour, burn.max(0) as u64)); + let burn = hours_of_day(burn_rows, generated_at, 30, zone); let lanes = lanes(timelines, 90, 5); // Layered back to front, bleeding off the edges: written-to-landed as a // starfield, the projects as ribbons, the calendar, then the wheel. @@ -637,6 +685,7 @@ fn dashboard_card( svg.push_str(&violins(&lanes, -20.0, 360.0, 1240.0, 54.0, 0.0, 0.0)); svg.push_str(&calendar(days, 50.0, 40.0, 42.0)); svg.push_str(&wheel(&bins, 930.0, 300.0, 36.0, 300.0, None)); + svg.push_str(&pulse(&burn, 930.0, 300.0, 36.0, 300.0)); // The edges fall away into the page colour. svg.push_str(&format!( r##" @@ -922,6 +971,11 @@ mod tests { #[test] fn the_dashboard_card_renders() { let daily = fixture(); + let hour = 498034; // 2026-10-25T10:00Z + let burn = BurnSeries { + hours: vec![[hour - 30 * 24, 9_000], [hour, 1_000], [hour + 5, 400]], + ..BurnSeries::default() + }; // 200 changes over the last 100 days, sorted by gap as the build does. let gap = LandGap { gaps: (0..200).map(|n| n * n * 30).collect(), @@ -934,11 +988,18 @@ mod tests { &hourly_fixture(), &gap, &timelines_fixture(&daily), + Some(&burn), "2026-10-25T12:00:00Z", Some("America/New_York"), ); // No words at all: the post around the card carries them. assert!(!svg.contains("