diff --git a/internal/card/axis.go b/internal/card/axis.go
new file mode 100644
index 00000000..70c90dd0
--- /dev/null
+++ b/internal/card/axis.go
@@ -0,0 +1,42 @@
+package card
+
+import (
+ "math"
+ "strconv"
+)
+
+// niceTicks returns evenly-spaced tick values in [0, max] such that the step
+// is a 1/2/5/10 × 10^k number and the tick count is roughly targetTicks.
+//
+// Mirrors d3.scaleLinear().nice() / d3.axisLeft().ticks(n) so charts built
+// on top look visually consistent with the d3 reference.
+func niceTicks(max float64, targetTicks int) []float64 {
+ if max <= 0 || targetTicks <= 0 {
+ return []float64{0}
+ }
+ rough := max / float64(targetTicks)
+ exp := math.Pow(10, math.Floor(math.Log10(rough)))
+ frac := rough / exp
+ var step float64
+ switch {
+ case frac < 1.5:
+ step = 1 * exp
+ case frac < 3:
+ step = 2 * exp
+ case frac < 7:
+ step = 5 * exp
+ default:
+ step = 10 * exp
+ }
+
+ out := []float64{}
+ for v := 0.0; v <= max+step/1e9; v += step {
+ out = append(out, v)
+ }
+ return out
+}
+
+// formatTick renders a float tick label. Integer-valued ticks drop decimals.
+func formatTick(v float64) string {
+ return strconv.FormatFloat(v, 'f', -1, 64)
+}
diff --git a/internal/card/card_test.go b/internal/card/card_test.go
index 8b49139d..8e4a0047 100644
--- a/internal/card/card_test.go
+++ b/internal/card/card_test.go
@@ -29,8 +29,8 @@ func TestRenderAll(t *testing.T) {
{Name: "Python", Color: "#3572A5", Value: 150},
},
}
- p.Productive[2][14] = 7
- p.Productive[5][9] = 3
+ p.Productive[9] = 3
+ p.Productive[14] = 7
th, ok := theme.Lookup("dracula")
if !ok {
diff --git a/internal/card/donut_chart.go b/internal/card/donut_chart.go
new file mode 100644
index 00000000..50b82bf0
--- /dev/null
+++ b/internal/card/donut_chart.go
@@ -0,0 +1,112 @@
+package card
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/tiennm99/ghstats/internal/github"
+ "github.com/tiennm99/ghstats/internal/theme"
+)
+
+// renderDonutCard draws a donut chart with a left-side legend. Shared by the
+// repos-per-language and most-commit-language cards. Up to topN slices are
+// shown; smaller slices are grouped into "Other".
+func renderDonutCard(title string, stats []github.LangStat, t theme.Theme) []byte {
+ const (
+ width = 500
+ height = 220
+ topN = 6
+ cx = 380 // donut centre x
+ cy = 120 // donut centre y
+ outerR = 70.0 // donut outer radius
+ innerR = 38.0 // donut hole
+ legendX = 30
+ legendY0 = 70
+ legendDY = 22
+ swatchSz = 12
+ )
+
+ stats = collapseOther(stats, topN)
+
+ var b strings.Builder
+ b.WriteString(header(width, height, t.Background, t.Title, title))
+
+ if len(stats) == 0 {
+ fmt.Fprintf(&b, `
+ No data available.`, t.Muted)
+ b.WriteString(footer)
+ return []byte(b.String())
+ }
+
+ var total int64
+ for _, s := range stats {
+ total += s.Value
+ }
+
+ // Legend (square + name + percentage).
+ for i, s := range stats {
+ pct := 100 * float64(s.Value) / float64(total)
+ y := legendY0 + i*legendDY
+ fmt.Fprintf(&b, `
+
+ %s %.2f%%`,
+ legendX, y-swatchSz+2, swatchSz, swatchSz,
+ colorOrAccent(s.Color, t.Accent), t.Background,
+ legendX+swatchSz+8, y, t.Text,
+ escapeXML(s.Name), pct)
+ }
+
+ // Donut slices.
+ start := -math.Pi / 2 // 12 o'clock start
+ for _, s := range stats {
+ angle := 2 * math.Pi * float64(s.Value) / float64(total)
+ end := start + angle
+ large := 0
+ if angle > math.Pi {
+ large = 1
+ }
+ sx, sy := polar(cx, cy, outerR, start)
+ ex, ey := polar(cx, cy, outerR, end)
+ isx, isy := polar(cx, cy, innerR, end)
+ iex, iey := polar(cx, cy, innerR, start)
+ fmt.Fprintf(&b, `
+ `,
+ sx, sy, outerR, outerR, large, ex, ey,
+ isx, isy, innerR, innerR, large, iex, iey,
+ colorOrAccent(s.Color, t.Accent), t.Background)
+ start = end
+ }
+
+ b.WriteString(footer)
+ return []byte(b.String())
+}
+
+// polar returns the cartesian coordinate at (r, angle) around (cx, cy).
+// Angle is in radians, measured clockwise from 3 o'clock (standard SVG).
+func polar(cx, cy float64, r, angle float64) (float64, float64) {
+ return cx + r*math.Cos(angle), cy + r*math.Sin(angle)
+}
+
+// collapseOther returns the top (n-1) slices plus an "Other" row summing the
+// rest. When the slice fits, it's returned as-is.
+func collapseOther(in []github.LangStat, n int) []github.LangStat {
+ if len(in) <= n {
+ return in
+ }
+ out := make([]github.LangStat, 0, n)
+ out = append(out, in[:n-1]...)
+ var rest int64
+ for _, s := range in[n-1:] {
+ rest += s.Value
+ }
+ out = append(out, github.LangStat{Name: "Other", Value: rest})
+ return out
+}
+
+func colorOrAccent(c, fallback string) string {
+ if c == "" {
+ return fallback
+ }
+ return c
+}
diff --git a/internal/card/language_bar.go b/internal/card/language_bar.go
deleted file mode 100644
index 42a66506..00000000
--- a/internal/card/language_bar.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package card
-
-import (
- "fmt"
- "strings"
-
- "github.com/tiennm99/ghstats/internal/github"
- "github.com/tiennm99/ghstats/internal/theme"
-)
-
-// renderLanguageCard draws a horizontal stacked bar + legend from a list of
-// LangStats. Shared by the repos-per-language and most-commit-language cards.
-//
-// title is the card heading; empty is rendered as the "no data" fallback.
-func renderLanguageCard(title string, stats []github.LangStat, t theme.Theme) []byte {
- const (
- width = 500
- height = 220
- topN = 6
- barX = 25
- barY = 60
- barW = 450
- barH = 10
- legendX0 = 25
- )
-
- var b strings.Builder
- b.WriteString(header(width, height, t.Background, t.Title, title))
-
- if len(stats) > topN {
- stats = stats[:topN]
- }
-
- if len(stats) == 0 {
- fmt.Fprintf(&b, `
- No data available.`, t.Muted)
- b.WriteString(footer)
- return []byte(b.String())
- }
-
- var total int64
- for _, s := range stats {
- total += s.Value
- }
-
- fmt.Fprintf(&b, `
-
- `,
- barX, barY, barW, barH, t.Muted)
-
- offset := float64(barX)
- for _, s := range stats {
- w := float64(barW) * float64(s.Value) / float64(total)
- fmt.Fprintf(&b, `
- `,
- offset, barY, w, barH, colorOrAccent(s.Color, t.Accent))
- offset += w
- }
- b.WriteString(`
- `)
-
- for i, s := range stats {
- col := i % 2
- row := i / 2
- x := legendX0 + col*230
- y := 110 + row*24
- pct := 100 * float64(s.Value) / float64(total)
- fmt.Fprintf(&b, `
-
- %s %.2f%%`,
- x+6, y-4, colorOrAccent(s.Color, t.Accent),
- x+20, y, t.Text, escapeXML(s.Name), pct)
- }
-
- b.WriteString(footer)
- return []byte(b.String())
-}
-
-func colorOrAccent(c, fallback string) string {
- if c == "" {
- return fallback
- }
- return c
-}
diff --git a/internal/card/most_commit_language.go b/internal/card/most_commit_language.go
index 684a2e4a..27323014 100644
--- a/internal/card/most_commit_language.go
+++ b/internal/card/most_commit_language.go
@@ -10,5 +10,5 @@ type mostCommitLanguageCard struct{}
func (mostCommitLanguageCard) Filename() string { return "2-most-commit-language.svg" }
func (mostCommitLanguageCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
- return renderLanguageCard("Most Commit Language (last year)", p.CommitsByLanguage, t), nil
+ return renderDonutCard("Most Commit Language (last year)", p.CommitsByLanguage, t), nil
}
diff --git a/internal/card/productive.go b/internal/card/productive.go
index 15dbdf64..519448e9 100644
--- a/internal/card/productive.go
+++ b/internal/card/productive.go
@@ -12,71 +12,82 @@ type productiveCard struct{}
func (productiveCard) Filename() string { return "4-productive-time.svg" }
-var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
+// Hour ticks to label on the x-axis; same set the reference project uses.
+var xTickHours = [...]int{0, 6, 12, 18, 23}
func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
const (
- width = 650
- height = 240
- cellSize = 18
- cellGap = 3
- gridX = 55
- gridY = 60
+ width = 500
+ height = 220
+ leftAxis = 50
+ rightPad = 25
+ topPad = 60
+ chartH = 110
+ barGap = 2
)
+ chartW := width - leftAxis - rightPad
+ barW := float64(chartW-barGap*23) / 24.0
var b strings.Builder
- b.WriteString(header(width, height, t.Background, t.Title, "Productive Time (last year, by hour)"))
+ b.WriteString(header(width, height, t.Background, t.Title, "Commits by Hour (last year)"))
max := 0
- for _, row := range p.Productive {
- for _, v := range row {
- if v > max {
- max = v
- }
+ for _, v := range p.Productive {
+ if v > max {
+ max = v
}
}
+ yMax := float64(max)
+ if yMax == 0 {
+ yMax = 1
+ }
+ ticks := niceTicks(yMax, 5)
+ if len(ticks) > 0 {
+ yMax = ticks[len(ticks)-1]
+ }
- // Weekday labels.
- for i, d := range weekdayLabels {
- y := gridY + i*(cellSize+cellGap) + cellSize - 4
+ // Y-axis: vertical line + tick marks with labels.
+ fmt.Fprintf(&b, `
+ `,
+ leftAxis, topPad, leftAxis, topPad+chartH, t.Muted)
+ for _, v := range ticks {
+ y := topPad + chartH - int(float64(chartH)*v/yMax)
fmt.Fprintf(&b, `
- %s`,
- y, t.Muted, d)
+
+ %s`,
+ leftAxis-4, y, leftAxis, y, t.Muted,
+ leftAxis-6, y+3, t.Muted, escapeXML(formatTick(v)))
}
- // Hour labels along top (every 3 hours).
- for h := 0; h < 24; h += 3 {
- x := gridX + h*(cellSize+cellGap)
+ // X-axis: horizontal line + tick labels.
+ fmt.Fprintf(&b, `
+ `,
+ leftAxis, topPad+chartH, leftAxis+chartW, topPad+chartH, t.Muted)
+ for _, h := range xTickHours {
+ x := leftAxis + int(barW*float64(h)+float64(barGap*h)+barW/2)
fmt.Fprintf(&b, `
- %02dh`,
- x, t.Muted, h)
+
+ %d`,
+ x, topPad+chartH, x, topPad+chartH+4, t.Muted,
+ x, topPad+chartH+16, t.Muted, h)
}
- // Cells.
- for d := 0; d < 7; d++ {
- for h := 0; h < 24; h++ {
- count := p.Productive[d][h]
- opacity := heatOpacity(count, max)
- x := gridX + h*(cellSize+cellGap)
- y := gridY + d*(cellSize+cellGap)
- fmt.Fprintf(&b, `
- %s %02d:00 — %d commits`,
- x, y, cellSize, cellSize, t.Accent, opacity,
- weekdayLabels[d], h, count)
- }
+ // Bars.
+ for h := 0; h < 24; h++ {
+ count := p.Productive[h]
+ barH := float64(chartH) * float64(count) / yMax
+ x := float64(leftAxis) + barW*float64(h) + float64(barGap*h)
+ y := float64(topPad+chartH) - barH
+ fmt.Fprintf(&b, `
+ %02d:00 — %d commits`,
+ x, y, barW, barH, t.Accent, h, count)
}
+ // X-axis caption.
+ fmt.Fprintf(&b, `
+ hour of day`,
+ leftAxis+chartW/2, topPad+chartH+34, t.Muted)
+
b.WriteString(footer)
return []byte(b.String()), nil
}
-
-// heatOpacity returns the fill-opacity for a cell. Zero is almost transparent
-// so the grid is still visible; max-count maps to fully opaque.
-func heatOpacity(count, max int) float64 {
- if max == 0 {
- return 0.08
- }
- const floor = 0.10
- ratio := float64(count) / float64(max)
- return floor + (1.0-floor)*ratio
-}
diff --git a/internal/card/repos_per_language.go b/internal/card/repos_per_language.go
index d63745ab..29db8eb4 100644
--- a/internal/card/repos_per_language.go
+++ b/internal/card/repos_per_language.go
@@ -10,5 +10,5 @@ type reposPerLanguageCard struct{}
func (reposPerLanguageCard) Filename() string { return "1-repos-per-language.svg" }
func (reposPerLanguageCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
- return renderLanguageCard("Repos Per Language", p.ReposByLanguage, t), nil
+ return renderDonutCard("Repos Per Language", p.ReposByLanguage, t), nil
}
diff --git a/internal/github/model.go b/internal/github/model.go
index fb61db82..6c03dbc7 100644
--- a/internal/github/model.go
+++ b/internal/github/model.go
@@ -35,8 +35,8 @@ type Profile struct {
// primary language, sorted desc. Populated by FetchProductive.
CommitsByLanguage []LangStat
- // Commit-count histogram indexed by [day-of-week 0=Sunday][hour-of-day 0-23].
- Productive [7][24]int
+ // Commit counts grouped by hour-of-day (0-23) in the configured timezone.
+ Productive [24]int
// TopRepos are owned repos sorted by stargazer count desc. Populated by
// FetchProfile and consumed by FetchProductive.
diff --git a/internal/github/productive.go b/internal/github/productive.go
index 82ef3e1c..db2e8e30 100644
--- a/internal/github/productive.go
+++ b/internal/github/productive.go
@@ -23,7 +23,7 @@ type productiveGQL struct {
} `json:"repository"`
}
-// FetchProductive fills p.Productive with a [7][24] commit histogram over the
+// FetchProductive fills p.Productive with a 24-hour commit histogram over the
// last year and p.CommitsByLanguage with commit counts attributed to each
// repo's primary language. Commits are gathered from the given repos (usually
// p.TopRepos[:N]); each repo is sampled up to maxPerRepo commits to keep the
@@ -71,8 +71,7 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio
if err != nil {
continue
}
- tl := t.In(loc)
- p.Productive[int(tl.Weekday())][tl.Hour()]++
+ p.Productive[t.In(loc).Hour()]++
if repo.PrimaryLanguage != "" {
commitsByLang[repo.PrimaryLanguage]++
if _, ok := langColor[repo.PrimaryLanguage]; !ok {