Files
wrapped/pkg/reports/image_export.go
T
2026-05-01 19:32:12 -06:00

781 lines
16 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 embeddedFontData []byte
var embeddedFontPath string
func init() {
tempDir := os.TempDir()
embeddedFontPath = filepath.Join(tempDir, "BitCount.ttf")
if _, err := os.Stat(embeddedFontPath); os.IsNotExist(err) {
if err := os.WriteFile(embeddedFontPath, embeddedFontData, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write embedded font: %v\n", err)
}
}
}
const (
canvasWidth = 2100
canvasHeight = 1740
gridGap = 30
gridColumnW = 487
cardRadius = 36
cardPadding = 36
cardBorderW = 1
defaultBarH = 21
defaultBarRad = 6
)
type cardKey string
const (
cardTotalCommits cardKey = "total_commits"
cardRepos cardKey = "repositories"
cardActiveDay cardKey = "active_day"
cardAvgCommits cardKey = "avg_commits"
cardActiveMonth cardKey = "active_month"
cardTopRepo cardKey = "top_repo"
cardLanguages cardKey = "languages"
cardActivity cardKey = "activity"
)
type gridSize struct {
Cols int
Rows int
}
type gridPosition struct {
Col int
Row int
ColSpan int
RowSpan int
}
type cardStyle struct {
Background color.RGBA
Accent color.RGBA
}
type cardSpec struct {
Key cardKey
Layout gridPosition
Style cardStyle
Renderer func(*gg.Context, bentoCard, *models.UserStats)
}
type bentoCard struct {
Key cardKey
X float64
Y float64
W float64
H float64
Background color.RGBA
Accent color.RGBA
}
// Bento card rows
var bentoRowHeights = []int{
360,
360,
450,
420,
}
// Bento card styling
var bentoCardSpecs = []cardSpec{
{
Key: cardTotalCommits,
Layout: gridPosition{
Col: 0, Row: 0,
ColSpan: 2, RowSpan: 1,
},
Style: cardStyle{Background: cardBgPurple, Accent: accentMagenta},
Renderer: renderCommitsCard,
},
{
Key: cardRepos,
Layout: gridPosition{
Col: 2, Row: 0,
ColSpan: 1, RowSpan: 1,
},
Style: cardStyle{Background: cardBg, Accent: accentOrange},
Renderer: renderReposCard,
},
{
Key: cardActiveDay,
Layout: gridPosition{
Col: 3, Row: 0,
ColSpan: 1, RowSpan: 1,
},
Style: cardStyle{Background: cardBg, Accent: accentCyan},
Renderer: renderActiveDayCard,
},
{
Key: cardAvgCommits,
Layout: gridPosition{
Col: 0, Row: 1,
ColSpan: 1, RowSpan: 1,
},
Style: cardStyle{Background: cardBg, Accent: accentGreen},
Renderer: renderAvgCard,
},
{
Key: cardActiveMonth,
Layout: gridPosition{
Col: 1, Row: 1,
ColSpan: 2, RowSpan: 1,
},
Style: cardStyle{Background: cardBg, Accent: accentOrange},
Renderer: renderActiveMonthCard,
},
{
Key: cardTopRepo,
Layout: gridPosition{
Col: 3, Row: 1,
ColSpan: 1, RowSpan: 1,
},
Style: cardStyle{Background: cardBgBlue, Accent: accentBlue},
Renderer: renderTopRepoCard,
},
{
Key: cardLanguages,
Layout: gridPosition{
Col: 0, Row: 2,
ColSpan: 4, RowSpan: 1,
},
Style: cardStyle{Background: cardBg, Accent: accentMagenta},
Renderer: renderLanguagesCard,
},
{
Key: cardActivity,
Layout: gridPosition{
Col: 0, Row: 3,
ColSpan: 4, RowSpan: 1,
},
Style: cardStyle{Background: cardBg, Accent: accentCyan},
Renderer: renderActivityCard,
},
}
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}
)
func buildBentoCards(specs []cardSpec) []bentoCard {
cards := make([]bentoCard, 0, len(specs))
for _, spec := range specs {
x := gridX(spec.Layout.Col)
y := gridY(spec.Layout.Row)
w := gridWidth(spec.Layout.ColSpan)
h := gridHeight(spec.Layout.Row, spec.Layout.RowSpan)
cards = append(cards, bentoCard{
Key: spec.Key,
X: x,
Y: y,
W: w,
H: h,
Background: spec.Style.Background,
Accent: spec.Style.Accent,
})
}
return cards
}
func gridX(col int) float64 {
return float64(gridGap + col*(gridColumnW+gridGap))
}
func gridY(row int) float64 {
y := gridGap
for _, h := range bentoRowHeights[:row] {
y += h + gridGap
}
return float64(y)
}
func gridWidth(colSpan int) float64 {
return float64(colSpan*gridColumnW + (colSpan-1)*gridGap)
}
func gridHeight(startRow int, rowSpan int) float64 {
height := 0
for row := startRow; row < startRow+rowSpan; row++ {
height += bentoRowHeights[row]
}
height += (rowSpan - 1) * gridGap
return float64(height)
}
func cardInnerW(card bentoCard) float64 {
return card.W - cardPadding*2
}
func cardLeft(card bentoCard) float64 {
return card.X + cardPadding
}
func cardTop(card bentoCard) float64 {
return card.Y + cardPadding
}
func loadFont(ctx *gg.Context, size float64) {
if err := ctx.LoadFontFace(embeddedFontPath, size); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to load embedded font: %v\n", err)
}
}
func drawCard(ctx *gg.Context, card bentoCard) {
ctx.DrawRoundedRectangle(card.X, card.Y, card.W, card.H, cardRadius)
ctx.SetColor(card.Background)
ctx.Fill()
ctx.DrawRoundedRectangle(card.X, card.Y, card.W, card.H, cardRadius)
ctx.SetColor(borderColor)
ctx.SetLineWidth(cardBorderW)
ctx.Stroke()
}
func drawCardLabel(ctx *gg.Context, card bentoCard, text string) {
loadFont(ctx, 18)
ctx.SetColor(textSecondary)
lines := wrapText(text, cardInnerW(card), ctx)
for i, line := range lines {
ctx.DrawString(line, cardLeft(card), card.Y+48+float64(i)*24)
}
}
func drawMetric(ctx *gg.Context, card bentoCard, value string, subtitle string) {
loadFont(ctx, 96)
ctx.SetColor(card.Accent)
valueLines := wrapText(value, cardInnerW(card), ctx)
for i, line := range valueLines {
ctx.DrawString(line, cardLeft(card), card.Y+165+float64(i)*110)
}
if subtitle == "" {
return
}
loadFont(ctx, 21)
ctx.SetColor(textSecondary)
subtitleLines := wrapText(subtitle, cardInnerW(card), ctx)
for i, line := range subtitleLines {
ctx.DrawString(line, cardLeft(card), card.Y+204+float64(i)*28)
}
}
func drawLargeText(ctx *gg.Context, card bentoCard, text string, fontSize float64) {
loadFont(ctx, fontSize*1.5)
ctx.SetColor(card.Accent)
lines := wrapText(text, cardInnerW(card), ctx)
lineHeight := fontSize * 1.5 * 1.2
for i, line := range lines {
ctx.DrawString(line, cardLeft(card), card.Y+165+float64(i)*lineHeight)
}
}
func drawFilledBar(
ctx *gg.Context,
x float64,
y float64,
width float64,
height float64,
percent float64,
fill color.RGBA,
) {
ctx.DrawRoundedRectangle(x, y, width, height, defaultBarRad)
ctx.SetColor(barTrack)
ctx.Fill()
fillWidth := width * percent
if fillWidth < 8 {
fillWidth = 8
}
ctx.DrawRoundedRectangle(x, y, fillWidth, height, defaultBarRad)
ctx.SetColor(fill)
ctx.Fill()
}
func renderCommitsCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "TOTAL COMMITS")
drawMetric(
ctx,
card,
formatNumber(stats.TotalCommits),
fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay),
)
barX := cardLeft(card)
barY := card.Y + card.H - 60
barW := cardInnerW(card)
ctx.DrawRoundedRectangle(barX, barY, barW, 15, 7)
ctx.SetColor(color.RGBA{70, 50, 90, 255})
ctx.Fill()
ctx.DrawRoundedRectangle(barX, barY, barW*0.72, 15, 7)
ctx.SetColor(card.Accent)
ctx.Fill()
}
func renderReposCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "REPOSITORIES")
drawMetric(ctx, card, formatNumber(stats.TotalRepositories), "worked on this year")
y := card.Y + 240
maxTextWidth := cardInnerW(card)
for index, repo := range stats.TopRepositories {
if index >= 3 {
break
}
loadFont(ctx, 18)
ctx.SetColor(textMuted)
ctx.DrawString(truncatePx(repo.Name, maxTextWidth, ctx), cardLeft(card), y)
y += 36
}
}
func renderActiveDayCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "MOST ACTIVE DAY")
drawLargeText(ctx, card, stats.MostActiveDay, 72)
if len(stats.CommitsByWeekday) == 0 {
return
}
loadFont(ctx, 21)
ctx.SetColor(textSecondary)
ctx.DrawString(
fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count),
cardLeft(card),
card.Y+204,
)
}
func renderAvgCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "AVG COMMITS / DAY")
drawMetric(ctx, card, fmt.Sprintf("%.1f", stats.AverageCommitsPerDay), "daily average")
}
func renderActiveMonthCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "MOST ACTIVE MONTH")
drawLargeText(ctx, card, stats.MostActiveMonth, 66)
}
func renderTopRepoCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "TOP REPOSITORY")
if len(stats.TopRepositories) == 0 {
return
}
topRepo := stats.TopRepositories[0]
maxTextWidth := cardInnerW(card)
loadFont(ctx, 48)
ctx.SetColor(card.Accent)
lines := wrapText(topRepo.Name, maxTextWidth, ctx)
y := card.Y + 150
for index, line := range lines {
if index > 0 {
y -= 12
}
ctx.DrawString(line, cardLeft(card), y)
y += 60
}
loadFont(ctx, 19)
ctx.SetColor(textSecondary)
ctx.DrawString("your #1 repository this year", cardLeft(card), y+10)
y += 60
for index := 1; index < len(stats.TopRepositories) && index < 4; index++ {
loadFont(ctx, 18)
ctx.SetColor(textMuted)
repoLabel := fmt.Sprintf("#%d %s", index+1, stats.TopRepositories[index].Name)
repoLines := wrapText(repoLabel, maxTextWidth-10, ctx)
for lineIndex, line := range repoLines {
ctx.DrawString(line, cardLeft(card), y)
y += 24
if lineIndex > 0 {
y -= 2
}
}
y += 6
}
}
func renderLanguagesCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "LANGUAGES")
if len(stats.Languages) == 0 {
loadFont(ctx, 22)
ctx.SetColor(textSecondary)
ctx.DrawString("No language data available.", cardLeft(card), card.Y+150)
return
}
const (
columnCount = 2
rowsPerColumn = 5
rowHeight = 72.0
startYOffset = 90.0
labelWidth = 165.0
countWidth = 135.0
)
maxCount := stats.Languages[0].Count
columnWidth := (cardInnerW(card) - gridGap) / columnCount
for index, language := range stats.Languages {
if index >= columnCount*rowsPerColumn {
break
}
columnIndex := index / rowsPerColumn
rowIndex := index % rowsPerColumn
baseX := cardLeft(card) + float64(columnIndex)*(columnWidth+gridGap)
baseY := card.Y + startYOffset + float64(rowIndex)*rowHeight
percentOfMax := float64(language.Count) / float64(maxCount)
barWidth := columnWidth - labelWidth - countWidth - 15
loadFont(ctx, 19)
ctx.SetColor(textPrimary)
ctx.DrawString(
truncatePx(language.Language, labelWidth-12, ctx),
baseX,
baseY+18,
)
drawFilledBar(
ctx,
baseX+labelWidth,
baseY+3,
barWidth,
defaultBarH,
percentOfMax,
card.Accent,
)
loadFont(ctx, 18)
ctx.SetColor(textSecondary)
ctx.DrawString(
fmt.Sprintf("%d %.1f%%", language.Count, language.Percent),
baseX+labelWidth+barWidth+15,
baseY+19,
)
}
}
func renderActivityCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) {
drawCardLabel(ctx, card, "ACTIVITY PATTERNS")
const (
startYOffset = 81.0
rowHeight = 42.0
labelWidth = 66.0
countWidth = 75.0
)
columnWidth := (cardInnerW(card) - gridGap) / 2
renderWeekdayActivity(
ctx,
stats,
cardLeft(card),
card.Y+startYOffset,
columnWidth,
labelWidth,
countWidth,
rowHeight,
)
renderMonthActivity(
ctx,
stats,
cardLeft(card)+columnWidth+gridGap,
card.Y+startYOffset,
columnWidth,
labelWidth,
countWidth,
rowHeight,
)
}
func renderWeekdayActivity(
ctx *gg.Context,
stats *models.UserStats,
x float64,
y float64,
columnWidth float64,
labelWidth float64,
countWidth float64,
rowHeight float64,
) {
loadFont(ctx, 18)
ctx.SetColor(textSecondary)
ctx.DrawString("By Weekday", x, y)
if len(stats.CommitsByWeekday) == 0 {
return
}
maxCount := stats.CommitsByWeekday[0].Count
barWidth := columnWidth - labelWidth - countWidth - 15
for index, day := range stats.CommitsByWeekday {
if index >= 7 {
break
}
rowY := y + 36 + float64(index)*rowHeight
loadFont(ctx, 18)
ctx.SetColor(textPrimary)
ctx.DrawString(day.Day[:3], x, rowY+18)
percentOfMax := float64(day.Count) / float64(maxCount)
drawFilledBar(
ctx,
x+labelWidth,
rowY+3,
barWidth,
defaultBarH,
percentOfMax,
accentCyan,
)
loadFont(ctx, 16)
ctx.SetColor(textMuted)
ctx.DrawString(formatNumber(day.Count), x+labelWidth+barWidth+12, rowY+18)
}
}
func renderMonthActivity(
ctx *gg.Context,
stats *models.UserStats,
x float64,
y float64,
columnWidth float64,
labelWidth float64,
countWidth float64,
rowHeight float64,
) {
loadFont(ctx, 18)
ctx.SetColor(textSecondary)
ctx.DrawString("By Month", x, y)
if len(stats.CommitsByMonth) == 0 {
return
}
maxCount := stats.CommitsByMonth[0].Count
barWidth := columnWidth - labelWidth - countWidth - 15
for index, month := range stats.CommitsByMonth {
if index >= 7 {
break
}
rowY := y + 36 + float64(index)*rowHeight
loadFont(ctx, 18)
ctx.SetColor(textPrimary)
ctx.DrawString(month.Date.Format("Jan"), x, rowY+18)
percentOfMax := float64(month.Count) / float64(maxCount)
drawFilledBar(
ctx,
x+labelWidth,
rowY+3,
barWidth,
defaultBarH,
percentOfMax,
accentOrange,
)
loadFont(ctx, 16)
ctx.SetColor(textMuted)
ctx.DrawString(formatNumber(month.Count), x+labelWidth+barWidth+12, rowY+18)
}
}
func ExportReportAsImage(stats *models.UserStats, format string) (string, error) {
resultsDir, err := config.GetResultsDir()
if err != nil {
return "", err
}
ctx := gg.NewContext(canvasWidth, canvasHeight)
ctx.SetColor(bgColor)
ctx.Clear()
cards := buildBentoCards(bentoCardSpecs)
for _, card := range cards {
drawCard(ctx, card)
}
for index, spec := range bentoCardSpecs {
spec.Renderer(ctx, cards[index], stats)
}
filenameWithExt := buildReportFilename(format)
filename := filepath.Join(resultsDir, filenameWithExt)
if err := writeImage(ctx, filename, format); err != nil {
return "", err
}
return filenameWithExt, nil
}
func GenerateReportImage(stats *models.UserStats, format string) (string, error) {
return ExportReportAsImage(stats, format)
}
func buildReportFilename(format string) string {
timestamp := time.Now().Format("2006-01-02 15-04-05")
return fmt.Sprintf("%s - Gitea Wrapped.%s", timestamp, format)
}
func writeImage(ctx *gg.Context, filename string, format string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
switch format {
case "png":
return png.Encode(file, ctx.Image())
case "jpg", "jpeg":
return jpeg.Encode(file, ctx.Image(), &jpeg.Options{Quality: 92})
default:
return fmt.Errorf("unsupported format: %s", format)
}
}
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 truncatePx(text string, maxWidth float64, ctx *gg.Context) string {
width, _ := ctx.MeasureString(text)
if width <= maxWidth {
return text
}
for len(text) > 1 {
text = text[:len(text)-1]
width, _ = ctx.MeasureString(text + "…")
if width <= maxWidth {
return text + "…"
}
}
return text
}
func wrapText(text string, maxWidth float64, ctx *gg.Context) []string {
words := strings.Fields(text)
if len(words) == 0 {
return nil
}
var lines []string
currentLine := ""
for _, word := range words {
testLine := word
if currentLine != "" {
testLine = currentLine + " " + word
}
width, _ := ctx.MeasureString(testLine)
if width <= maxWidth {
currentLine = testLine
continue
}
if currentLine != "" {
lines = append(lines, currentLine)
}
currentLine = word
}
if currentLine != "" {
lines = append(lines, currentLine)
}
return lines
}