Compare commits
10 Commits
283b2126c0
...
2130032d46
| Author | SHA1 | Date | |
|---|---|---|---|
|
2130032d46
|
|||
|
92a66422ab
|
|||
|
94b6fbe42d
|
|||
|
048646a7c1
|
|||
|
1f2fea3427
|
|||
|
d539892b70
|
|||
|
a57682e610
|
|||
|
22c80fd7af
|
|||
|
8b5656ef26
|
|||
|
dd1de38b2f
|
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1271,6 +1271,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"fontdb",
|
||||||
"moments-core",
|
"moments-core",
|
||||||
"moments-data",
|
"moments-data",
|
||||||
"moments-entities",
|
"moments-entities",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
|||||||
figment = { version = "0.10", features = ["toml", "env"] }
|
figment = { version = "0.10", features = ["toml", "env"] }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
resvg = "0.45"
|
resvg = "0.45"
|
||||||
|
fontdb = "0.23"
|
||||||
|
|
||||||
# internal
|
# internal
|
||||||
moments-entities = { path = "crates/moments-entities", version = "=0.1.0" }
|
moments-entities = { path = "crates/moments-entities", version = "=0.1.0" }
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ chrono.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
resvg.workspace = true
|
resvg.workspace = true
|
||||||
|
fontdb.workspace = true
|
||||||
|
|||||||
@@ -227,14 +227,21 @@ fn render_contributions_png(
|
|||||||
|
|
||||||
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
|
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
|
||||||
|
|
||||||
let cell = 10_f64;
|
// OG image canvas: 1200x630
|
||||||
let gap = 2_f64;
|
let og_w = 1200_f64;
|
||||||
let step = cell + gap;
|
let og_h = 630_f64;
|
||||||
let radius = cell / 2.0;
|
let padding = 40_f64;
|
||||||
let year_label_w = 40_f64;
|
|
||||||
let max_cols = 53;
|
|
||||||
let bg = "#2c3e50";
|
let bg = "#2c3e50";
|
||||||
|
|
||||||
|
let year_label_w = 50_f64;
|
||||||
|
let max_cols = 53;
|
||||||
|
// Scale cell size to fill available width
|
||||||
|
let avail_w = og_w - 2.0 * padding - year_label_w;
|
||||||
|
let step = (avail_w / max_cols as f64).floor();
|
||||||
|
let gap = (step * 0.17).round();
|
||||||
|
let cell = step - gap;
|
||||||
|
let radius = cell / 2.0;
|
||||||
|
|
||||||
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||||
|
|
||||||
// Build weekly data per year
|
// Build weekly data per year
|
||||||
@@ -253,7 +260,6 @@ fn render_contributions_png(
|
|||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(yr, 12, 31).unwrap()
|
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 offset = year_start.weekday().num_days_from_sunday();
|
||||||
let mut cursor = year_start - chrono::Duration::days(offset as i64);
|
let mut cursor = year_start - chrono::Duration::days(offset as i64);
|
||||||
|
|
||||||
@@ -294,35 +300,54 @@ fn render_contributions_png(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let n_rows = rows.len();
|
let n_rows = rows.len();
|
||||||
let title_h = 28_f64;
|
let graph_h = (n_rows as f64) * step;
|
||||||
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 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}"/>"#,
|
|
||||||
);
|
|
||||||
let repo_text = if repo_count > 0 {
|
let repo_text = if repo_count > 0 {
|
||||||
format!(" in {repo_count} repositories")
|
format!(" in {repo_count} repositories")
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Layout: headline at top, graph vertically centered in remaining space
|
||||||
|
let offset_x = padding;
|
||||||
|
let headline_y = padding + 36.0;
|
||||||
|
let subtitle_y = headline_y + 28.0;
|
||||||
|
let graph_top = subtitle_y + 16.0;
|
||||||
|
let avail_graph_h = og_h - graph_top - padding;
|
||||||
|
let graph_y = graph_top + (avail_graph_h - graph_h).max(0.0) / 2.0;
|
||||||
|
|
||||||
|
let mut svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{og_w}" height="{og_h}" viewBox="0 0 {og_w} {og_h}"><rect width="100%" height="100%" fill="{bg}"/>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Headline
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
r##"<text x="{x}" y="18" fill="#ecf0f1" font-size="12" opacity="0.6">{total} contributions since {from}{repo_text}</text>"##,
|
r##"<text x="{x}" y="{y}" fill="#ecf0f1" font-family="sans-serif" font-size="36" font-weight="bold">rob thijssen</text>"##,
|
||||||
x = year_label_w,
|
x = offset_x + year_label_w,
|
||||||
|
y = headline_y,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
svg.push_str(&format!(
|
||||||
|
r##"<text x="{x}" y="{y}" fill="#ecf0f1" font-family="sans-serif" font-size="16" opacity="0.6">{total} contributions since {from}{repo_text}</text>"##,
|
||||||
|
x = offset_x + year_label_w,
|
||||||
|
y = subtitle_y,
|
||||||
|
));
|
||||||
|
|
||||||
|
let label_font_size = (step * 0.7).round().max(8.0).min(14.0);
|
||||||
|
|
||||||
for (row_idx, row) in rows.iter().enumerate() {
|
for (row_idx, row) in rows.iter().enumerate() {
|
||||||
let y_base = title_h + (row_idx as f64) * step;
|
let y_base = graph_y + (row_idx as f64) * step;
|
||||||
svg.push_str(&format!(
|
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>"##,
|
r##"<text x="{x}" y="{y}" text-anchor="end" dominant-baseline="central" fill="#ecf0f1" font-family="sans-serif" font-size="{fs}" opacity="0.6">{yr}</text>"##,
|
||||||
x = year_label_w - 4.0,
|
x = offset_x + year_label_w - 6.0,
|
||||||
y = y_base + radius,
|
y = y_base + radius,
|
||||||
|
fs = label_font_size,
|
||||||
yr = row.year,
|
yr = row.year,
|
||||||
));
|
));
|
||||||
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
|
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
|
||||||
let cx = year_label_w + (col as f64) * step + radius;
|
let cx = offset_x + year_label_w + (col as f64) * step + radius;
|
||||||
let cy = y_base + radius;
|
let cy = y_base + radius;
|
||||||
let fill = color_for(*count);
|
let fill = color_for(*count);
|
||||||
svg.push_str(&format!(
|
svg.push_str(&format!(
|
||||||
@@ -334,15 +359,17 @@ fn render_contributions_png(
|
|||||||
|
|
||||||
svg.push_str("</svg>");
|
svg.push_str("</svg>");
|
||||||
|
|
||||||
// Rasterize with resvg
|
// Rasterize at 1200x630
|
||||||
let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default())
|
let mut fontdb = fontdb::Database::new();
|
||||||
|
fontdb.load_system_fonts();
|
||||||
|
let mut opts = resvg::usvg::Options::default();
|
||||||
|
opts.fontdb = std::sync::Arc::new(fontdb);
|
||||||
|
opts.font_family = "Noto Sans".to_owned();
|
||||||
|
let tree = resvg::usvg::Tree::from_str(&svg, &opts)
|
||||||
.map_err(|e| format!("svg parse: {e}"))?;
|
.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 =
|
let mut pixmap =
|
||||||
resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| "pixmap alloc failed".to_string())?;
|
resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32).ok_or_else(|| "pixmap alloc failed".to_string())?;
|
||||||
|
|
||||||
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,74 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>rob.tn</title>
|
<title>rob.tn</title>
|
||||||
<meta property="og:title" content="rob thijssen" />
|
<meta
|
||||||
<meta property="og:description" content="contribution history across github, gitea, and mozilla hg" />
|
name="description"
|
||||||
<meta property="og:image" content="https://rob.tn/api/v1/og/contributions.png" />
|
content="a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012."
|
||||||
<meta property="og:type" content="website" />
|
/>
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta
|
||||||
<meta name="twitter:image" content="https://rob.tn/api/v1/og/contributions.png" />
|
property="og:title"
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
content="rob thijssen: developer activity and contribution history"
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
/>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<meta
|
||||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
|
property="og:description"
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
content="a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012."
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
/>
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
<meta
|
||||||
</head>
|
property="og:image"
|
||||||
<body>
|
content="https://rob.tn/api/v1/og/contributions.png"
|
||||||
<div id="root"></div>
|
width="1200"
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
height="630"
|
||||||
</body>
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://rob.tn/" />
|
||||||
|
<meta property="og:site_name" content="rob.tn" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:logo" content="https://rob.tn/icon-512.png" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta
|
||||||
|
name="twitter:image"
|
||||||
|
content="https://rob.tn/api/v1/og/contributions.png"
|
||||||
|
width="1200"
|
||||||
|
height="630"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/favicon-16.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/favicon-32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="48x48"
|
||||||
|
href="/favicon-48.png"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="192x192"
|
||||||
|
href="/icon-192.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="512x512"
|
||||||
|
href="/icon-512.png"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -120,22 +120,21 @@ export function ContributionStats() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
|
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
|
||||||
<div className="d-flex flex-column gap-1 mt-1">
|
<div className="d-flex align-items-end gap-1 mt-1" style={{ height: 64 }}>
|
||||||
{stats.dayAvgs.map(({ name, avg }) => (
|
{stats.dayAvgs.map(({ name, avg }) => (
|
||||||
<div key={name} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
|
<div key={name} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
|
||||||
<span style={{ width: 24, textAlign: 'right', opacity: 0.7 }}>{name}</span>
|
<span style={{ fontSize: '0.65rem', opacity: 0.6, marginBottom: 2 }}>{avg.toFixed(1)}</span>
|
||||||
<div style={{ flex: 1, height: 8, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
<div style={{ width: '100%', maxWidth: 20, borderRadius: 3, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
|
height: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
|
||||||
height: '100%',
|
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
backgroundColor: '#39d353',
|
backgroundColor: '#39d353',
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ width: 28, textAlign: 'right', opacity: 0.6 }}>{avg.toFixed(1)}</span>
|
<span style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: 2 }}>{name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { fetchRepoLanguages } from '../api/client';
|
import { fetchRepoLanguages } from '../api/client';
|
||||||
|
|
||||||
const MAX_LANGS = 10;
|
const MAX_LANGS = 14;
|
||||||
|
|
||||||
export function TopLanguages() {
|
export function TopLanguages() {
|
||||||
const langsQ = useQuery({
|
const langsQ = useQuery({
|
||||||
|
|||||||
Reference in New Issue
Block a user