Files
wrapped/pkg/reports/image_export.go
T

466 lines
11 KiB
Go

package reports
import (
_ "embed"
"fmt"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"time"
"github.com/atridad/wrapped-cli/pkg/config"
"github.com/atridad/wrapped-cli/pkg/models"
"github.com/fogleman/gg"
)
//go:embed BitCount.ttf
var fontData []byte
var fontPath string
func init() {
tmpDir := os.TempDir()
fontPath = filepath.Join(tmpDir, "BitCount.ttf")
if _, err := os.Stat(fontPath); os.IsNotExist(err) {
if err := os.WriteFile(fontPath, fontData, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write embedded font: %v\n", err)
}
}
}
const (
canvasW = 2100
canvasH = 1740
gridGap = 30
radius = 36
unitCol = 487
twoCol = 1005
fourCol = 2040
)
var (
bgColor = color.RGBA{9, 9, 11, 255}
cardBg = color.RGBA{24, 24, 27, 255}
cardBgPurple = color.RGBA{30, 20, 36, 255}
cardBgBlue = color.RGBA{15, 23, 42, 255}
accentMagenta = color.RGBA{220, 90, 255, 255}
accentCyan = color.RGBA{0, 244, 255, 255}
accentOrange = color.RGBA{255, 150, 0, 255}
accentGreen = color.RGBA{34, 255, 100, 255}
accentBlue = color.RGBA{100, 180, 255, 255}
textPrimary = color.RGBA{255, 255, 255, 255}
textSecondary = color.RGBA{200, 200, 215, 255}
textMuted = color.RGBA{150, 150, 170, 255}
borderColor = color.RGBA{60, 60, 70, 255}
barTrack = color.RGBA{50, 50, 65, 255}
)
type bentoCard struct {
x, y, w, h float64
bg color.RGBA
accent color.RGBA
}
func col(n int) float64 {
return float64(gridGap + n*(unitCol+gridGap))
}
func cardLayout() []bentoCard {
r1y := float64(gridGap)
r1h := float64(360)
r2y := r1y + r1h + gridGap
r2h := float64(360)
r3y := r2y + r2h + gridGap
r3h := float64(450)
r4y := r3y + r3h + gridGap
r4h := float64(420)
return []bentoCard{
{col(0), r1y, twoCol, r1h, cardBgPurple, accentMagenta},
{col(2), r1y, unitCol, r1h, cardBg, accentOrange},
{col(3), r1y, unitCol, r1h, cardBg, accentCyan},
{col(0), r2y, unitCol, r2h, cardBg, accentGreen},
{col(1), r2y, unitCol, r2h, cardBg, accentOrange},
{col(2), r2y, twoCol, r2h, cardBgBlue, accentBlue},
{col(0), r3y, fourCol, r3h, cardBg, accentMagenta},
{col(0), r4y, fourCol, r4h, cardBg, accentCyan},
}
}
func loadFont(dc *gg.Context, size float64) {
if err := dc.LoadFontFace(fontPath, size); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to load embedded font: %v\n", err)
}
}
func drawCard(dc *gg.Context, c bentoCard) {
dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius)
dc.SetColor(c.bg)
dc.Fill()
dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius)
dc.SetColor(borderColor)
dc.SetLineWidth(1)
dc.Stroke()
}
func label(dc *gg.Context, c bentoCard, text string) {
loadFont(dc, 18)
dc.SetColor(textSecondary)
dc.DrawString(text, c.x+36, c.y+48)
}
func bigNum(dc *gg.Context, c bentoCard, value, sub string) {
loadFont(dc, 96)
dc.SetColor(c.accent)
dc.DrawString(value, c.x+36, c.y+165)
if sub != "" {
loadFont(dc, 21)
dc.SetColor(textSecondary)
dc.DrawString(sub, c.x+36, c.y+204)
}
}
func bigText(dc *gg.Context, c bentoCard, value string, size float64) {
loadFont(dc, size*1.5)
dc.SetColor(c.accent)
dc.DrawString(value, c.x+36, c.y+165)
}
func filledBar(dc *gg.Context, x, y, totalW, h, pct float64, fill color.RGBA) {
dc.DrawRoundedRectangle(x, y, totalW, h, 6)
dc.SetColor(barTrack)
dc.Fill()
fillW := totalW * pct
if fillW < 8 {
fillW = 8
}
dc.DrawRoundedRectangle(x, y, fillW, h, 6)
dc.SetColor(fill)
dc.Fill()
}
func renderCommitsCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "TOTAL COMMITS")
bigNum(dc, c, formatNumber(stats.TotalCommits),
fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay))
dc.DrawRoundedRectangle(c.x+36, c.y+c.h-60, c.w-72, 15, 7)
dc.SetColor(color.RGBA{70, 50, 90, 255})
dc.Fill()
dc.DrawRoundedRectangle(c.x+36, c.y+c.h-60, (c.w-72)*0.72, 15, 7)
dc.SetColor(c.accent)
dc.Fill()
}
func renderReposCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "REPOSITORIES")
bigNum(dc, c, formatNumber(stats.TotalRepositories), "worked on this year")
y := c.y + 240
maxW := c.w - 72
for i, repo := range stats.TopRepositories {
if i >= 3 {
break
}
loadFont(dc, 18)
dc.SetColor(textMuted)
dc.DrawString(truncate(repo.Name, maxW, dc), c.x+36, y)
y += 36
}
}
func renderActiveDayCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "MOST ACTIVE DAY")
bigText(dc, c, stats.MostActiveDay, 72)
if len(stats.CommitsByWeekday) > 0 {
loadFont(dc, 21)
dc.SetColor(textSecondary)
dc.DrawString(
fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count),
c.x+36, c.y+204,
)
}
}
func renderAvgCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "AVG COMMITS / DAY")
bigNum(dc, c, fmt.Sprintf("%.1f", stats.AverageCommitsPerDay), "daily average")
}
func renderActiveMonthCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "MOST ACTIVE MONTH")
bigText(dc, c, stats.MostActiveMonth, 66)
}
func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "TOP REPOSITORY")
if len(stats.TopRepositories) == 0 {
return
}
top := stats.TopRepositories[0]
maxW := c.w - 72
loadFont(dc, 48)
dc.SetColor(c.accent)
dc.DrawString(truncate(top.Name, maxW, dc), c.x+36, c.y+150)
loadFont(dc, 19)
dc.SetColor(textSecondary)
dc.DrawString("your #1 repository this year", c.x+36, c.y+189)
y := c.y + 246
for i := 1; i < len(stats.TopRepositories) && i < 4; i++ {
loadFont(dc, 18)
dc.SetColor(textMuted)
dc.DrawString(
fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-45, dc)),
c.x+36, y,
)
y += 36
}
}
func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "LANGUAGES")
if len(stats.Languages) == 0 {
loadFont(dc, 22)
dc.SetColor(textSecondary)
dc.DrawString("No language data available.", c.x+36, c.y+150)
return
}
const (
numCols = 2
rowsPerCol = 5
rowH = 72.0
startY = 90.0
labelW = 165.0
countW = 135.0
barH = 21.0
)
maxCount := stats.Languages[0].Count
halfW := (c.w - 72 - gridGap) / 2
for i, lang := range stats.Languages {
if i >= numCols*rowsPerCol {
break
}
colIdx := i / rowsPerCol
rowIdx := i % rowsPerCol
baseX := c.x + 36 + float64(colIdx)*(halfW+float64(gridGap))
baseY := c.y + startY + float64(rowIdx)*rowH
pct := float64(lang.Count) / float64(maxCount)
barW := halfW - labelW - countW - 15
loadFont(dc, 19)
dc.SetColor(textPrimary)
dc.DrawString(truncatePx(lang.Language, labelW-12, dc), baseX, baseY+18)
filledBar(dc, baseX+labelW, baseY+3, barW, barH, pct, c.accent)
loadFont(dc, 18)
dc.SetColor(textSecondary)
dc.DrawString(
fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent),
baseX+labelW+barW+15, baseY+19,
)
}
}
func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
label(dc, c, "ACTIVITY PATTERNS")
const (
startY = 81.0
rowH = 42.0
labelW = 66.0
countW = 75.0
barH = 21.0
)
halfW := (c.w - 72 - float64(gridGap)) / 2
lx := c.x + 36
loadFont(dc, 18)
dc.SetColor(textSecondary)
dc.DrawString("By Weekday", lx, c.y+startY)
if len(stats.CommitsByWeekday) > 0 {
maxC := stats.CommitsByWeekday[0].Count
barW := halfW - labelW - countW - 15
for i, day := range stats.CommitsByWeekday {
if i >= 7 {
break
}
by := c.y + startY + 36 + float64(i)*rowH
loadFont(dc, 18)
dc.SetColor(textPrimary)
dc.DrawString(day.Day[:3], lx, by+18)
pct := float64(day.Count) / float64(maxC)
filledBar(dc, lx+labelW, by+3, barW, barH, pct, accentCyan)
loadFont(dc, 16)
dc.SetColor(textMuted)
dc.DrawString(formatNumber(day.Count), lx+labelW+barW+12, by+18)
}
}
rx := c.x + 36 + halfW + float64(gridGap)
loadFont(dc, 18)
dc.SetColor(textSecondary)
dc.DrawString("By Month", rx, c.y+startY)
if len(stats.CommitsByMonth) > 0 {
maxC := stats.CommitsByMonth[0].Count
barW := halfW - labelW - countW - 15
for i, month := range stats.CommitsByMonth {
if i >= 7 {
break
}
by := c.y + startY + 36 + float64(i)*rowH
loadFont(dc, 18)
dc.SetColor(textPrimary)
dc.DrawString(month.Date.Format("Jan"), rx, by+18)
pct := float64(month.Count) / float64(maxC)
filledBar(dc, rx+labelW, by+3, barW, barH, pct, accentOrange)
loadFont(dc, 16)
dc.SetColor(textMuted)
dc.DrawString(formatNumber(month.Count), rx+labelW+barW+12, by+18)
}
}
}
func formatNumber(n int) string {
switch {
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
case n >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1_000)
default:
return fmt.Sprintf("%d", n)
}
}
func truncate(s string, maxPx float64, dc *gg.Context) string {
return truncatePx(s, maxPx, dc)
}
func truncatePx(s string, maxPx float64, dc *gg.Context) string {
w, _ := dc.MeasureString(s)
if w <= maxPx {
return s
}
for len(s) > 1 {
s = s[:len(s)-1]
w, _ = dc.MeasureString(s + "…")
if w <= maxPx {
return s + "…"
}
}
return s
}
func ExportReportAsImage(stats *models.UserStats, format string) (string, error) {
resultsDir, err := config.GetResultsDir()
if err != nil {
return "", err
}
dc := gg.NewContext(canvasW, canvasH)
dc.SetColor(bgColor)
dc.Clear()
cards := cardLayout()
for _, c := range cards {
drawCard(dc, c)
}
renderCommitsCard(dc, cards[0], stats)
renderReposCard(dc, cards[1], stats)
renderActiveDayCard(dc, cards[2], stats)
renderAvgCard(dc, cards[3], stats)
renderActiveMonthCard(dc, cards[4], stats)
renderTopRepoCard(dc, cards[5], stats)
renderLanguagesCard(dc, cards[6], stats)
renderActivityCard(dc, cards[7], stats)
now := time.Now()
timestamp := now.Format("2006-01-02 15-04-05")
filenameWithExt := fmt.Sprintf("%s - Gitea Wrapped.%s", timestamp, format)
filename := filepath.Join(resultsDir, filenameWithExt)
switch format {
case "png":
f, err := os.Create(filename)
if err != nil {
return "", err
}
defer f.Close()
if err := png.Encode(f, dc.Image()); err != nil {
return "", err
}
case "jpg", "jpeg":
f, err := os.Create(filename)
if err != nil {
return "", err
}
defer f.Close()
if err := jpeg.Encode(f, dc.Image(), &jpeg.Options{Quality: 92}); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported format: %s", format)
}
return filenameWithExt, nil
}
func GenerateReportImage(stats *models.UserStats, format string) (string, error) {
return ExportReportAsImage(stats, format)
}
func stripANSI(s string) string {
b := make([]byte, 0, len(s))
inEscape := false
for i := 0; i < len(s); i++ {
if s[i] == '\x1b' {
inEscape = true
} else if inEscape && s[i] == 'm' {
inEscape = false
} else if !inEscape {
b = append(b, s[i])
}
}
return string(b)
}
func getTextColor(line string) color.Color {
if strings.Contains(line, "Overview") ||
strings.Contains(line, "Languages") ||
strings.Contains(line, "Activity") {
return color.RGBA{0, 255, 255, 255}
}
return color.RGBA{200, 200, 200, 255}
}