feat(api): server-rendered OG image of all-time contribution graph
Add /v1/og/contributions.png endpoint that builds an SVG of the all-time weekly contribution graph (one row per year) from daily counts, then rasterizes to PNG via resvg. Served with 1h cache. Add og:image and twitter:card meta tags to index.html pointing at the endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
@@ -58,6 +58,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/v1/projects", get(list_projects))
|
||||
.route("/v1/activity/daily", get(daily_counts))
|
||||
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
||||
.route("/v1/og/contributions.png", get(og_contributions))
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive());
|
||||
@@ -156,6 +157,172 @@ async fn daily_counts(
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
async fn og_contributions(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Get date range from source summaries
|
||||
let summaries = state
|
||||
.store
|
||||
.source_summaries(false)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
let earliest = summaries
|
||||
.iter()
|
||||
.filter_map(|s| s.earliest)
|
||||
.min()
|
||||
.unwrap_or_else(|| Utc::now())
|
||||
.date_naive();
|
||||
let today = Utc::now().date_naive();
|
||||
|
||||
let counts = state
|
||||
.store
|
||||
.daily_counts(earliest, today)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let png = render_contributions_png(&counts, earliest, today).map_err(|e| ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: e,
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "image/png"),
|
||||
(axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
|
||||
],
|
||||
png,
|
||||
))
|
||||
}
|
||||
|
||||
fn render_contributions_png(
|
||||
counts: &[DailyCount],
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
|
||||
|
||||
let cell = 10_f64;
|
||||
let gap = 2_f64;
|
||||
let step = cell + gap;
|
||||
let radius = cell / 2.0;
|
||||
let year_label_w = 40_f64;
|
||||
let max_cols = 53;
|
||||
let bg = "#2c3e50";
|
||||
|
||||
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||
|
||||
// Build weekly data per year
|
||||
struct YearRow {
|
||||
year: i32,
|
||||
weeks: Vec<(NaiveDate, NaiveDate, i64)>, // start, end, count
|
||||
}
|
||||
let start_year = from.year();
|
||||
let end_year = to.year();
|
||||
let mut rows: Vec<YearRow> = Vec::new();
|
||||
|
||||
for yr in start_year..=end_year {
|
||||
let year_start = NaiveDate::from_ymd_opt(yr, 1, 1).unwrap();
|
||||
let year_end = if yr == end_year {
|
||||
to
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(yr, 12, 31).unwrap()
|
||||
};
|
||||
// Align to preceding Sunday (weekday 6 = Sunday in chrono's Mon=0 scheme)
|
||||
let offset = year_start.weekday().num_days_from_sunday();
|
||||
let mut cursor = year_start - chrono::Duration::days(offset as i64);
|
||||
|
||||
let mut weeks = Vec::new();
|
||||
while cursor <= year_end {
|
||||
let week_start = cursor;
|
||||
let mut week_count = 0i64;
|
||||
for _ in 0..7 {
|
||||
week_count += count_map.get(&cursor).copied().unwrap_or(0);
|
||||
cursor += chrono::Duration::days(1);
|
||||
}
|
||||
let week_end = cursor - chrono::Duration::days(1);
|
||||
weeks.push((week_start, week_end, week_count));
|
||||
}
|
||||
rows.push(YearRow { year: yr, weeks });
|
||||
}
|
||||
|
||||
// Quantile thresholds
|
||||
let mut non_zero: Vec<i64> = rows
|
||||
.iter()
|
||||
.flat_map(|r| r.weeks.iter().map(|w| w.2))
|
||||
.filter(|&c| c > 0)
|
||||
.collect();
|
||||
non_zero.sort();
|
||||
let thresholds = if non_zero.is_empty() {
|
||||
[1i64, 2, 3]
|
||||
} else {
|
||||
let p = |pct: f64| non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize];
|
||||
[p(0.25), p(0.5), p(0.75)]
|
||||
};
|
||||
|
||||
let color_for = |count: i64| -> &str {
|
||||
if count == 0 { colors[0] }
|
||||
else if count <= thresholds[0] { colors[1] }
|
||||
else if count <= thresholds[1] { colors[2] }
|
||||
else if count <= thresholds[2] { colors[3] }
|
||||
else { colors[4] }
|
||||
};
|
||||
|
||||
let n_rows = rows.len();
|
||||
let title_h = 28_f64;
|
||||
let svg_w = year_label_w + (max_cols as f64) * step;
|
||||
let svg_h = title_h + (n_rows as f64) * step + 8.0;
|
||||
|
||||
let total: i64 = counts.iter().map(|d| d.count).sum();
|
||||
|
||||
let mut svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}"><rect width="100%" height="100%" fill="{bg}"/>"#,
|
||||
);
|
||||
svg.push_str(&format!(
|
||||
r##"<text x="{x}" y="18" fill="#ecf0f1" font-size="12" opacity="0.6">{total} contributions since {from}</text>"##,
|
||||
x = year_label_w,
|
||||
));
|
||||
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let y_base = title_h + (row_idx as f64) * step;
|
||||
svg.push_str(&format!(
|
||||
r##"<text x="{x}" y="{y}" text-anchor="end" dominant-baseline="central" fill="#ecf0f1" font-size="9" opacity="0.6">{yr}</text>"##,
|
||||
x = year_label_w - 4.0,
|
||||
y = y_base + radius,
|
||||
yr = row.year,
|
||||
));
|
||||
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
|
||||
let cx = year_label_w + (col as f64) * step + radius;
|
||||
let cy = y_base + radius;
|
||||
let fill = color_for(*count);
|
||||
svg.push_str(&format!(
|
||||
r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}"/>"#,
|
||||
r = radius - 1.0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
svg.push_str("</svg>");
|
||||
|
||||
// Rasterize with resvg
|
||||
let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default())
|
||||
.map_err(|e| format!("svg parse: {e}"))?;
|
||||
|
||||
let size = tree.size();
|
||||
let w = size.width().ceil() as u32;
|
||||
let h = size.height().ceil() as u32;
|
||||
let mut pixmap =
|
||||
resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| "pixmap alloc failed".to_string())?;
|
||||
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
||||
|
||||
pixmap
|
||||
.encode_png()
|
||||
.map_err(|e| format!("png encode: {e}"))
|
||||
}
|
||||
|
||||
/// Allowlisted forge hosts that the proxy may contact.
|
||||
const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user