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 {