149 lines
3.8 KiB
Python
149 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
import json
|
|
import math
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
|
|
COLORS = [
|
|
"#ebedf0",
|
|
"#9be9a8",
|
|
"#40c463",
|
|
"#30a14e",
|
|
"#216e39",
|
|
]
|
|
|
|
CELL = 12
|
|
GAP = 2
|
|
RECT = CELL - GAP
|
|
LEFT_PAD = 30
|
|
TOP_PAD = 20
|
|
RIGHT_PAD = 10
|
|
BOTTOM_PAD = 10
|
|
FONT_FAMILY = "-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif"
|
|
LABEL_COLOR = "#57606a"
|
|
|
|
|
|
def load_heatmap(path):
|
|
with path.open() as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def date_range(start, end):
|
|
current = start
|
|
while current <= end:
|
|
yield current
|
|
current += timedelta(days=1)
|
|
|
|
|
|
def previous_sunday(day):
|
|
return day - timedelta(days=(day.weekday() + 1) % 7)
|
|
|
|
|
|
def next_saturday(day):
|
|
return day + timedelta(days=(5 - day.weekday()) % 7)
|
|
|
|
|
|
def intensity(count, max_count):
|
|
if count <= 0 or max_count <= 0:
|
|
return 0
|
|
return max(1, min(4, math.ceil((count / max_count) * 4)))
|
|
|
|
|
|
def weekday_row(day):
|
|
return (day.weekday() + 1) % 7
|
|
|
|
|
|
def month_labels(start, end):
|
|
labels = [(0, start.strftime("%b"))]
|
|
total_weeks = ((end - start).days // 7) + 1
|
|
|
|
for week in range(total_weeks):
|
|
week_start = start + timedelta(days=week * 7)
|
|
for offset in range(7):
|
|
day = week_start + timedelta(days=offset)
|
|
if day.day == 1 and week != 0:
|
|
labels.append((week, day.strftime("%b")))
|
|
break
|
|
|
|
return labels
|
|
|
|
|
|
def build_rect(day, start, counts, max_count):
|
|
date_str = day.isoformat()
|
|
count = counts.get(date_str, 0)
|
|
level = intensity(count, max_count)
|
|
x = LEFT_PAD + (((day - start).days // 7) * CELL)
|
|
y = TOP_PAD + (weekday_row(day) * CELL)
|
|
|
|
commit_label = "commit" if count == 1 else "commits"
|
|
|
|
return (
|
|
f'<rect x="{x}" y="{y}" width="{RECT}" height="{RECT}" rx="2" ry="2" '
|
|
f'fill="{COLORS[level]}">'
|
|
f"<title>{date_str}: {count} {commit_label}</title>"
|
|
"</rect>"
|
|
)
|
|
|
|
|
|
def generate_svg(data):
|
|
counts = {entry["date"]: entry["count"] for entry in data["days"]}
|
|
max_count = data["max_daily_commits"]
|
|
|
|
start = datetime.strptime(data["from_date"], "%Y-%m-%d").date()
|
|
end = datetime.strptime(data["to_date"], "%Y-%m-%d").date()
|
|
aligned_start = previous_sunday(start)
|
|
aligned_end = next_saturday(end)
|
|
weeks = ((aligned_end - aligned_start).days // 7) + 1
|
|
|
|
width = LEFT_PAD + (weeks * CELL) + RIGHT_PAD
|
|
height = TOP_PAD + (7 * CELL) + BOTTOM_PAD
|
|
|
|
svg = [
|
|
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
|
'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Commit activity heatmap">',
|
|
'<style>',
|
|
f'text {{ font: 10px {FONT_FAMILY}; fill: {LABEL_COLOR}; }}',
|
|
'</style>',
|
|
]
|
|
|
|
for week, label in month_labels(aligned_start, aligned_end):
|
|
x = LEFT_PAD + (week * CELL)
|
|
svg.append(f'<text x="{x}" y="10">{label}</text>')
|
|
|
|
for label, row in (("Mon", 1), ("Wed", 3), ("Fri", 5)):
|
|
y = TOP_PAD + (row * CELL) + 8
|
|
svg.append(f'<text x="0" y="{y}">{label}</text>')
|
|
|
|
for day in date_range(aligned_start, aligned_end):
|
|
svg.append(build_rect(day, aligned_start, counts, max_count))
|
|
|
|
svg.append("</svg>")
|
|
return "\n".join(svg) + "\n"
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) not in {2, 3}:
|
|
print(
|
|
"Usage: generate_heatmap_svg.py <heatmap.json> [output.svg]",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
input_path = Path(sys.argv[1])
|
|
output = generate_svg(load_heatmap(input_path))
|
|
|
|
if len(sys.argv) == 3:
|
|
output_path = Path(sys.argv[2])
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(output)
|
|
else:
|
|
sys.stdout.write(output)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|