diff --git a/pkg/reports/image_export.go b/pkg/reports/image_export.go index 3d6803e..36ae4d9 100644 --- a/pkg/reports/image_export.go +++ b/pkg/reports/image_export.go @@ -17,30 +17,167 @@ import ( ) //go:embed BitCount.ttf -var fontData []byte +var embeddedFontData []byte -var fontPath string +var embeddedFontPath 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 { + 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 ( - canvasW = 2100 - canvasH = 1740 - gridGap = 30 - radius = 36 - unitCol = 487 - twoCol = 1005 - fourCol = 2040 + 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} @@ -58,296 +195,523 @@ var ( barTrack = color.RGBA{50, 50, 65, 255} ) -type bentoCard struct { - x, y, w, h float64 - bg color.RGBA - accent color.RGBA -} +func buildBentoCards(specs []cardSpec) []bentoCard { + cards := make([]bentoCard, 0, len(specs)) -func col(n int) float64 { - return float64(gridGap + n*(unitCol+gridGap)) -} + for _, spec := range specs { + x := gridX(spec.Layout.Col) + y := gridY(spec.Layout.Row) -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) + w := gridWidth(spec.Layout.ColSpan) + h := gridHeight(spec.Layout.Row, spec.Layout.RowSpan) - 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}, + 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 loadFont(dc *gg.Context, size float64) { - if err := dc.LoadFontFace(fontPath, size); err != nil { +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(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 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 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 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 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 +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) } - 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 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 renderReposCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { - label(dc, c, "REPOSITORIES") - bigNum(dc, c, formatNumber(stats.TotalRepositories), "worked on this year") +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() - y := c.y + 240 - maxW := c.w - 72 - for i, repo := range stats.TopRepositories { - if i >= 3 { + 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(dc, 18) - dc.SetColor(textMuted) - dc.DrawString(truncate(repo.Name, maxW, dc), c.x+36, y) + + loadFont(ctx, 18) + ctx.SetColor(textMuted) + ctx.DrawString(truncatePx(repo.Name, maxTextWidth, ctx), cardLeft(card), 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) +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 { - loadFont(dc, 21) - dc.SetColor(textSecondary) - dc.DrawString( - fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count), - c.x+36, c.y+204, - ) + 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(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 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(dc *gg.Context, c bentoCard, stats *models.UserStats) { - label(dc, c, "MOST ACTIVE MONTH") - bigText(dc, c, stats.MostActiveMonth, 66) +func renderActiveMonthCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { + drawCardLabel(ctx, card, "MOST ACTIVE MONTH") + drawLargeText(ctx, card, stats.MostActiveMonth, 66) } -func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { - label(dc, c, "TOP REPOSITORY") +func renderTopRepoCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { + drawCardLabel(ctx, card, "TOP REPOSITORY") if len(stats.TopRepositories) == 0 { return } - top := stats.TopRepositories[0] - maxW := c.w - 72 + topRepo := stats.TopRepositories[0] + maxTextWidth := cardInnerW(card) - loadFont(dc, 48) - dc.SetColor(c.accent) - dc.DrawString(truncate(top.Name, maxW, dc), c.x+36, c.y+150) + loadFont(ctx, 48) + ctx.SetColor(card.Accent) - loadFont(dc, 19) - dc.SetColor(textSecondary) - dc.DrawString("your #1 repository this year", c.x+36, c.y+189) + lines := wrapText(topRepo.Name, maxTextWidth, ctx) + y := card.Y + 150 - 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 + 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(dc *gg.Context, c bentoCard, stats *models.UserStats) { - label(dc, c, "LANGUAGES") +func renderLanguagesCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { + drawCardLabel(ctx, card, "LANGUAGES") if len(stats.Languages) == 0 { - loadFont(dc, 22) - dc.SetColor(textSecondary) - dc.DrawString("No language data available.", c.x+36, c.y+150) + loadFont(ctx, 22) + ctx.SetColor(textSecondary) + ctx.DrawString("No language data available.", cardLeft(card), card.Y+150) return } const ( - numCols = 2 - rowsPerCol = 5 - rowH = 72.0 - startY = 90.0 - labelW = 165.0 - countW = 135.0 - barH = 21.0 + columnCount = 2 + rowsPerColumn = 5 + rowHeight = 72.0 + startYOffset = 90.0 + labelWidth = 165.0 + countWidth = 135.0 ) maxCount := stats.Languages[0].Count - halfW := (c.w - 72 - gridGap) / 2 + columnWidth := (cardInnerW(card) - gridGap) / columnCount - for i, lang := range stats.Languages { - if i >= numCols*rowsPerCol { + for index, language := range stats.Languages { + if index >= columnCount*rowsPerColumn { break } - colIdx := i / rowsPerCol - rowIdx := i % rowsPerCol + columnIndex := index / rowsPerColumn + rowIndex := index % rowsPerColumn - baseX := c.x + 36 + float64(colIdx)*(halfW+float64(gridGap)) - baseY := c.y + startY + float64(rowIdx)*rowH + baseX := cardLeft(card) + float64(columnIndex)*(columnWidth+gridGap) + baseY := card.Y + startYOffset + float64(rowIndex)*rowHeight - pct := float64(lang.Count) / float64(maxCount) - barW := halfW - labelW - countW - 15 + percentOfMax := float64(language.Count) / float64(maxCount) + barWidth := columnWidth - labelWidth - countWidth - 15 - loadFont(dc, 19) - dc.SetColor(textPrimary) - dc.DrawString(truncatePx(lang.Language, labelW-12, dc), baseX, baseY+18) + loadFont(ctx, 19) + ctx.SetColor(textPrimary) + ctx.DrawString( + truncatePx(language.Language, labelWidth-12, ctx), + baseX, + baseY+18, + ) - filledBar(dc, baseX+labelW, baseY+3, barW, barH, pct, c.accent) + drawFilledBar( + ctx, + baseX+labelWidth, + baseY+3, + barWidth, + defaultBarH, + percentOfMax, + card.Accent, + ) - loadFont(dc, 18) - dc.SetColor(textSecondary) - dc.DrawString( - fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent), - baseX+labelW+barW+15, baseY+19, + loadFont(ctx, 18) + ctx.SetColor(textSecondary) + ctx.DrawString( + fmt.Sprintf("%d %.1f%%", language.Count, language.Percent), + baseX+labelWidth+barWidth+15, + baseY+19, ) } } -func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { - label(dc, c, "ACTIVITY PATTERNS") +func renderActivityCard(ctx *gg.Context, card bentoCard, stats *models.UserStats) { + drawCardLabel(ctx, card, "ACTIVITY PATTERNS") const ( - startY = 81.0 - rowH = 42.0 - labelW = 66.0 - countW = 75.0 - barH = 21.0 + startYOffset = 81.0 + rowHeight = 42.0 + labelWidth = 66.0 + countWidth = 75.0 ) - halfW := (c.w - 72 - float64(gridGap)) / 2 - lx := c.x + 36 + columnWidth := (cardInnerW(card) - gridGap) / 2 - loadFont(dc, 18) - dc.SetColor(textSecondary) - dc.DrawString("By Weekday", lx, c.y+startY) + renderWeekdayActivity( + ctx, + stats, + cardLeft(card), + card.Y+startYOffset, + columnWidth, + labelWidth, + countWidth, + rowHeight, + ) - if len(stats.CommitsByWeekday) > 0 { - maxC := stats.CommitsByWeekday[0].Count - barW := halfW - labelW - countW - 15 + renderMonthActivity( + ctx, + stats, + cardLeft(card)+columnWidth+gridGap, + card.Y+startYOffset, + columnWidth, + labelWidth, + countWidth, + rowHeight, + ) +} - for i, day := range stats.CommitsByWeekday { - if i >= 7 { - break - } - by := c.y + startY + 36 + float64(i)*rowH +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) - 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) - } + if len(stats.CommitsByWeekday) == 0 { + return } - rx := c.x + 36 + halfW + float64(gridGap) + maxCount := stats.CommitsByWeekday[0].Count + barWidth := columnWidth - labelWidth - countWidth - 15 - 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) + 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) } } @@ -362,104 +726,55 @@ func formatNumber(n int) string { } } -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 +func truncatePx(text string, maxWidth float64, ctx *gg.Context) string { + width, _ := ctx.MeasureString(text) + if width <= maxWidth { + return text } - for len(s) > 1 { - s = s[:len(s)-1] - w, _ = dc.MeasureString(s + "…") - if w <= maxPx { - return s + "…" + + for len(text) > 1 { + text = text[:len(text)-1] + width, _ = ctx.MeasureString(text + "…") + + if width <= maxWidth { + return text + "…" } } - return s + + return text } -func ExportReportAsImage(stats *models.UserStats, format string) (string, error) { - resultsDir, err := config.GetResultsDir() - if err != nil { - return "", err +func wrapText(text string, maxWidth float64, ctx *gg.Context) []string { + words := strings.Fields(text) + if len(words) == 0 { + return nil } - dc := gg.NewContext(canvasW, canvasH) - dc.SetColor(bgColor) - dc.Clear() + var lines []string + currentLine := "" - cards := cardLayout() - for _, c := range cards { - drawCard(dc, c) + 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 } - 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) + if currentLine != "" { + lines = append(lines, currentLine) } - 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} + return lines }