diff --git a/pkg/reports/BitCount.ttf b/pkg/reports/BitCount.ttf new file mode 100644 index 0000000..e149e2b Binary files /dev/null and b/pkg/reports/BitCount.ttf differ diff --git a/pkg/reports/image_export.go b/pkg/reports/image_export.go index 7dba7cc..3d6803e 100644 --- a/pkg/reports/image_export.go +++ b/pkg/reports/image_export.go @@ -1,6 +1,7 @@ package reports import ( + _ "embed" "fmt" "image/color" "image/jpeg" @@ -15,14 +16,29 @@ import ( "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 = 1400 - canvasH = 1160 - gridGap = 20 - radius = 24 - unitCol = 325 - twoCol = 670 - fourCol = 1360 + canvasW = 2100 + canvasH = 1740 + gridGap = 30 + radius = 36 + unitCol = 487 + twoCol = 1005 + fourCol = 2040 ) var ( @@ -30,16 +46,16 @@ var ( cardBg = color.RGBA{24, 24, 27, 255} cardBgPurple = color.RGBA{30, 20, 36, 255} cardBgBlue = color.RGBA{15, 23, 42, 255} - accentMagenta = color.RGBA{192, 132, 252, 255} - accentCyan = color.RGBA{45, 212, 191, 255} - accentOrange = color.RGBA{251, 146, 60, 255} - accentGreen = color.RGBA{74, 222, 128, 255} - accentBlue = color.RGBA{96, 165, 250, 255} - textPrimary = color.RGBA{244, 244, 245, 255} - textSecondary = color.RGBA{161, 161, 170, 255} - textMuted = color.RGBA{113, 113, 122, 255} - borderColor = color.RGBA{39, 39, 42, 255} - barTrack = color.RGBA{39, 39, 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 { @@ -54,13 +70,13 @@ func col(n int) float64 { func cardLayout() []bentoCard { r1y := float64(gridGap) - r1h := float64(240) + r1h := float64(360) r2y := r1y + r1h + gridGap - r2h := float64(240) + r2h := float64(360) r3y := r2y + r2h + gridGap - r3h := float64(300) + r3h := float64(450) r4y := r3y + r3h + gridGap - r4h := float64(280) + r4h := float64(420) return []bentoCard{ {col(0), r1y, twoCol, r1h, cardBgPurple, accentMagenta}, @@ -75,17 +91,8 @@ func cardLayout() []bentoCard { } func loadFont(dc *gg.Context, size float64) { - paths := []string{ - "/System/Library/Fonts/Helvetica.ttc", - "/System/Library/Fonts/SFNS.ttf", - "/System/Library/Fonts/Supplemental/Arial.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - } - for _, p := range paths { - if err := dc.LoadFontFace(p, size); err == nil { - return - } + if err := dc.LoadFontFace(fontPath, size); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load embedded font: %v\n", err) } } @@ -100,27 +107,27 @@ func drawCard(dc *gg.Context, c bentoCard) { } func label(dc *gg.Context, c bentoCard, text string) { - loadFont(dc, 12) + loadFont(dc, 18) dc.SetColor(textSecondary) - dc.DrawString(text, c.x+24, c.y+32) + dc.DrawString(text, c.x+36, c.y+48) } func bigNum(dc *gg.Context, c bentoCard, value, sub string) { - loadFont(dc, 64) + loadFont(dc, 96) dc.SetColor(c.accent) - dc.DrawString(value, c.x+24, c.y+110) + dc.DrawString(value, c.x+36, c.y+165) if sub != "" { - loadFont(dc, 14) + loadFont(dc, 21) dc.SetColor(textSecondary) - dc.DrawString(sub, c.x+24, c.y+136) + dc.DrawString(sub, c.x+36, c.y+204) } } func bigText(dc *gg.Context, c bentoCard, value string, size float64) { - loadFont(dc, size) + loadFont(dc, size*1.5) dc.SetColor(c.accent) - dc.DrawString(value, c.x+24, c.y+110) + dc.DrawString(value, c.x+36, c.y+165) } func filledBar(dc *gg.Context, x, y, totalW, h, pct float64, fill color.RGBA) { @@ -142,10 +149,10 @@ func renderCommitsCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { bigNum(dc, c, formatNumber(stats.TotalCommits), fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay)) - dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, c.w-48, 10, 5) - dc.SetColor(color.RGBA{50, 40, 60, 255}) + 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+24, c.y+c.h-40, (c.w-48)*0.72, 10, 5) + dc.DrawRoundedRectangle(c.x+36, c.y+c.h-60, (c.w-72)*0.72, 15, 7) dc.SetColor(c.accent) dc.Fill() } @@ -154,29 +161,29 @@ 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 + 160 - maxW := c.w - 48 + y := c.y + 240 + maxW := c.w - 72 for i, repo := range stats.TopRepositories { if i >= 3 { break } - loadFont(dc, 12) + loadFont(dc, 18) dc.SetColor(textMuted) - dc.DrawString(truncate(repo.Name, maxW, dc), c.x+24, y) - y += 24 + 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, 48) + bigText(dc, c, stats.MostActiveDay, 72) if len(stats.CommitsByWeekday) > 0 { - loadFont(dc, 14) + loadFont(dc, 21) dc.SetColor(textSecondary) dc.DrawString( fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count), - c.x+24, c.y+136, + c.x+36, c.y+204, ) } } @@ -188,7 +195,7 @@ func renderAvgCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { func renderActiveMonthCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { label(dc, c, "MOST ACTIVE MONTH") - bigText(dc, c, stats.MostActiveMonth, 44) + bigText(dc, c, stats.MostActiveMonth, 66) } func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { @@ -199,25 +206,25 @@ func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { } top := stats.TopRepositories[0] - maxW := c.w - 48 + maxW := c.w - 72 - loadFont(dc, 32) + loadFont(dc, 48) dc.SetColor(c.accent) - dc.DrawString(truncate(top.Name, maxW, dc), c.x+24, c.y+100) + dc.DrawString(truncate(top.Name, maxW, dc), c.x+36, c.y+150) - loadFont(dc, 13) + loadFont(dc, 19) dc.SetColor(textSecondary) - dc.DrawString("your #1 repository this year", c.x+24, c.y+126) + dc.DrawString("your #1 repository this year", c.x+36, c.y+189) - y := c.y + 164 + y := c.y + 246 for i := 1; i < len(stats.TopRepositories) && i < 4; i++ { - loadFont(dc, 12) + loadFont(dc, 18) dc.SetColor(textMuted) dc.DrawString( - fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-30, dc)), - c.x+24, y, + fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-45, dc)), + c.x+36, y, ) - y += 24 + y += 36 } } @@ -225,24 +232,24 @@ func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { label(dc, c, "LANGUAGES") if len(stats.Languages) == 0 { - loadFont(dc, 15) + loadFont(dc, 22) dc.SetColor(textSecondary) - dc.DrawString("No language data available.", c.x+24, c.y+100) + dc.DrawString("No language data available.", c.x+36, c.y+150) return } const ( numCols = 2 rowsPerCol = 5 - rowH = 48.0 - startY = 60.0 - labelW = 110.0 - countW = 90.0 - barH = 14.0 + rowH = 72.0 + startY = 90.0 + labelW = 165.0 + countW = 135.0 + barH = 21.0 ) maxCount := stats.Languages[0].Count - halfW := (c.w - 48 - gridGap) / 2 + halfW := (c.w - 72 - gridGap) / 2 for i, lang := range stats.Languages { if i >= numCols*rowsPerCol { @@ -252,23 +259,23 @@ func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { colIdx := i / rowsPerCol rowIdx := i % rowsPerCol - baseX := c.x + 24 + float64(colIdx)*(halfW+float64(gridGap)) + 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 - 10 + barW := halfW - labelW - countW - 15 - loadFont(dc, 13) + loadFont(dc, 19) dc.SetColor(textPrimary) - dc.DrawString(truncatePx(lang.Language, labelW-8, dc), baseX, baseY+12) + dc.DrawString(truncatePx(lang.Language, labelW-12, dc), baseX, baseY+18) - filledBar(dc, baseX+labelW, baseY+2, barW, barH, pct, c.accent) + filledBar(dc, baseX+labelW, baseY+3, barW, barH, pct, c.accent) - loadFont(dc, 12) + loadFont(dc, 18) dc.SetColor(textSecondary) dc.DrawString( fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent), - baseX+labelW+barW+10, baseY+13, + baseX+labelW+barW+15, baseY+19, ) } } @@ -277,69 +284,69 @@ func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) { label(dc, c, "ACTIVITY PATTERNS") const ( - startY = 54.0 - rowH = 28.0 - labelW = 44.0 - countW = 50.0 - barH = 14.0 + startY = 81.0 + rowH = 42.0 + labelW = 66.0 + countW = 75.0 + barH = 21.0 ) - halfW := (c.w - 48 - float64(gridGap)) / 2 - lx := c.x + 24 + halfW := (c.w - 72 - float64(gridGap)) / 2 + lx := c.x + 36 - loadFont(dc, 12) + 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 - 10 + barW := halfW - labelW - countW - 15 for i, day := range stats.CommitsByWeekday { if i >= 7 { break } - by := c.y + startY + 24 + float64(i)*rowH + by := c.y + startY + 36 + float64(i)*rowH - loadFont(dc, 12) + loadFont(dc, 18) dc.SetColor(textPrimary) - dc.DrawString(day.Day[:3], lx, by+12) + dc.DrawString(day.Day[:3], lx, by+18) pct := float64(day.Count) / float64(maxC) - filledBar(dc, lx+labelW, by+2, barW, barH, pct, accentCyan) + filledBar(dc, lx+labelW, by+3, barW, barH, pct, accentCyan) - loadFont(dc, 11) + loadFont(dc, 16) dc.SetColor(textMuted) - dc.DrawString(formatNumber(day.Count), lx+labelW+barW+8, by+12) + dc.DrawString(formatNumber(day.Count), lx+labelW+barW+12, by+18) } } - rx := c.x + 24 + halfW + float64(gridGap) + rx := c.x + 36 + halfW + float64(gridGap) - loadFont(dc, 12) + 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 - 10 + barW := halfW - labelW - countW - 15 for i, month := range stats.CommitsByMonth { if i >= 7 { break } - by := c.y + startY + 24 + float64(i)*rowH + by := c.y + startY + 36 + float64(i)*rowH - loadFont(dc, 12) + loadFont(dc, 18) dc.SetColor(textPrimary) - dc.DrawString(month.Date.Format("Jan"), rx, by+12) + dc.DrawString(month.Date.Format("Jan"), rx, by+18) pct := float64(month.Count) / float64(maxC) - filledBar(dc, rx+labelW, by+2, barW, barH, pct, accentOrange) + filledBar(dc, rx+labelW, by+3, barW, barH, pct, accentOrange) - loadFont(dc, 11) + loadFont(dc, 16) dc.SetColor(textMuted) - dc.DrawString(formatNumber(month.Count), rx+labelW+barW+8, by+12) + dc.DrawString(formatNumber(month.Count), rx+labelW+barW+12, by+18) } } }