781 lines
16 KiB
Go
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
|
|
}
|