mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-20 00:15:14 +00:00
Five new cards, all derived from data FetchProductive / FetchProfile already pull, so zero additional API calls: - contributions-heatmap: 7×53 calendar grid with a 5-bucket intensity ramp mixed from each theme's Background→Accent so palettes with no dedicated heat ramp still render sensibly. - streak: current streak, longest streak with date ranges, active/total days. - contributions-by-year: one bar per active year, peak year highlighted. - productive-weekday + -all-time: 7-bar day-of-week mirror of the hour-of-day cards; FetchProductive now also fills Weekday / WeekdayAllTime histograms during the same commit-history pass. - top-starred-repos: top 5 owned non-fork repos by stargazer count; threads Stars through RepoInfo. Card count: 9 → 14. Registered in allCards grouped by recency (last-year block, then all-time block). Render test extended to cover all new files and realistic daily-series inputs.
200 lines
5.9 KiB
Go
200 lines
5.9 KiB
Go
package card
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/tiennm99/ghstats/internal/github"
|
||
"github.com/tiennm99/ghstats/internal/theme"
|
||
)
|
||
|
||
type contributionsHeatmapCard struct{}
|
||
|
||
func (contributionsHeatmapCard) Filename() string { return "contributions-heatmap.svg" }
|
||
|
||
func (contributionsHeatmapCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||
return renderHeatmap("Contributions (last year)", p.DailyContributions, t), nil
|
||
}
|
||
|
||
// renderHeatmap draws the classic 7×N week grid. Sunday at top, Saturday at
|
||
// bottom, oldest week on the left. Cell color mixes theme.Background with
|
||
// theme.Accent in four intensity buckets so every palette inherits a usable
|
||
// heatmap without a separate color ramp in the theme schema.
|
||
func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme) []byte {
|
||
const (
|
||
width = 340
|
||
height = 200
|
||
cellSize = 9
|
||
cellGap = 2
|
||
leftPad = 28
|
||
topPad = 55
|
||
dayLabelDX = 22 // where weekday labels anchor (right of grid start)
|
||
)
|
||
|
||
var b strings.Builder
|
||
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title))
|
||
|
||
if len(days) == 0 {
|
||
fmt.Fprintf(&b, `
|
||
<text x="25" y="100" font-size="13" fill="%s">No contribution data available.</text>`, t.Muted)
|
||
b.WriteString(footer)
|
||
return []byte(b.String())
|
||
}
|
||
|
||
cells := padToWeekGrid(days)
|
||
weeks := len(cells) / 7
|
||
|
||
// Determine intensity buckets from non-zero percentiles so sparse users
|
||
// still get visible cells and prolific users don't saturate the top bucket.
|
||
buckets := intensityThresholds(cells)
|
||
ramp := [5]string{
|
||
mixHex(t.Background, t.Accent, 0.00),
|
||
mixHex(t.Background, t.Accent, 0.25),
|
||
mixHex(t.Background, t.Accent, 0.50),
|
||
mixHex(t.Background, t.Accent, 0.75),
|
||
mixHex(t.Background, t.Accent, 1.00),
|
||
}
|
||
|
||
// Weekday labels (Mon, Wed, Fri) printed only on alternating rows to
|
||
// avoid visual clutter; matches GitHub's own layout.
|
||
for i, label := range [7]string{"", "Mon", "", "Wed", "", "Fri", ""} {
|
||
if label == "" {
|
||
continue
|
||
}
|
||
y := topPad + i*(cellSize+cellGap) + cellSize - 1
|
||
fmt.Fprintf(&b, `
|
||
<text x="%d" y="%d" font-size="9" fill="%s" text-anchor="end">%s</text>`,
|
||
leftPad-4, y, t.Muted, label)
|
||
}
|
||
|
||
// Month labels across the top. We print each month the first time its
|
||
// first day appears in a week column, skipping consecutive duplicates.
|
||
lastMonth := time.Month(0)
|
||
for w := 0; w < weeks; w++ {
|
||
first := cells[w*7].Date
|
||
if first.Day() > 7 {
|
||
continue // the 1st of the month falls in an earlier week
|
||
}
|
||
if first.Month() == lastMonth {
|
||
continue
|
||
}
|
||
lastMonth = first.Month()
|
||
x := leftPad + w*(cellSize+cellGap)
|
||
fmt.Fprintf(&b, `
|
||
<text x="%d" y="%d" font-size="9" fill="%s">%s</text>`,
|
||
x, topPad-4, t.Muted, first.Month().String()[:3])
|
||
}
|
||
|
||
// Cells.
|
||
for w := 0; w < weeks; w++ {
|
||
for d := 0; d < 7; d++ {
|
||
cell := cells[w*7+d]
|
||
if cell.Date.IsZero() {
|
||
continue // padding slot before the first real day
|
||
}
|
||
fill := ramp[bucketFor(cell.Count, buckets)]
|
||
x := leftPad + w*(cellSize+cellGap)
|
||
y := topPad + d*(cellSize+cellGap)
|
||
fmt.Fprintf(&b, `
|
||
<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s"><title>%s — %d</title></rect>`,
|
||
x, y, cellSize, cellSize, fill,
|
||
cell.Date.Format("2006-01-02"), cell.Count)
|
||
}
|
||
}
|
||
|
||
// Legend: "Less ▢▢▢▢▢ More" at bottom right.
|
||
legendX := width - 110
|
||
legendY := height - 15
|
||
fmt.Fprintf(&b, `
|
||
<text x="%d" y="%d" font-size="9" fill="%s">Less</text>`, legendX, legendY, t.Muted)
|
||
for i, c := range ramp {
|
||
fmt.Fprintf(&b, `
|
||
<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s"/>`,
|
||
legendX+28+i*(cellSize+2), legendY-cellSize+2, cellSize, cellSize, c)
|
||
}
|
||
fmt.Fprintf(&b, `
|
||
<text x="%d" y="%d" font-size="9" fill="%s">More</text>`,
|
||
legendX+28+5*(cellSize+2)+2, legendY, t.Muted)
|
||
|
||
b.WriteString(footer)
|
||
return []byte(b.String())
|
||
}
|
||
|
||
// padToWeekGrid prepends zero-date slots so the returned slice is a clean
|
||
// weeks×7 grid starting on Sunday (index 0 = Sun, 6 = Sat).
|
||
func padToWeekGrid(days []github.DailyContribution) []github.DailyContribution {
|
||
if len(days) == 0 {
|
||
return nil
|
||
}
|
||
offset := int(days[0].Date.Weekday())
|
||
grid := make([]github.DailyContribution, offset+len(days))
|
||
copy(grid[offset:], days)
|
||
// Round trailing remainder up to a full week so the grid is rectangular.
|
||
if rem := len(grid) % 7; rem != 0 {
|
||
grid = append(grid, make([]github.DailyContribution, 7-rem)...)
|
||
}
|
||
return grid
|
||
}
|
||
|
||
// intensityThresholds picks four cutoffs from the non-zero counts so cells
|
||
// distribute across the 5-bucket ramp. Quartile-ish without a sort cost.
|
||
func intensityThresholds(cells []github.DailyContribution) [4]int {
|
||
var max int
|
||
for _, c := range cells {
|
||
if c.Count > max {
|
||
max = c.Count
|
||
}
|
||
}
|
||
if max == 0 {
|
||
return [4]int{1, 2, 3, 4}
|
||
}
|
||
// Simple linear split — works well for the common case. Power users with
|
||
// a long right tail still fall into bucket 4 without being clipped.
|
||
return [4]int{
|
||
1,
|
||
max / 4,
|
||
max / 2,
|
||
(3 * max) / 4,
|
||
}
|
||
}
|
||
|
||
func bucketFor(count int, thresholds [4]int) int {
|
||
switch {
|
||
case count <= 0:
|
||
return 0
|
||
case count < thresholds[1]:
|
||
return 1
|
||
case count < thresholds[2]:
|
||
return 2
|
||
case count < thresholds[3]:
|
||
return 3
|
||
default:
|
||
return 4
|
||
}
|
||
}
|
||
|
||
// mixHex blends two "#rrggbb" colors at the given ratio (0 returns a, 1 returns b).
|
||
// Non-hex or short inputs fall back to b so a misconfigured theme still renders.
|
||
func mixHex(a, b string, ratio float64) string {
|
||
ar, ag, ab, ok := parseHex(a)
|
||
br, bg, bb, okb := parseHex(b)
|
||
if !ok || !okb {
|
||
return b
|
||
}
|
||
r := int(float64(ar)*(1-ratio) + float64(br)*ratio)
|
||
g := int(float64(ag)*(1-ratio) + float64(bg)*ratio)
|
||
bl := int(float64(ab)*(1-ratio) + float64(bb)*ratio)
|
||
return fmt.Sprintf("#%02x%02x%02x", r, g, bl)
|
||
}
|
||
|
||
func parseHex(s string) (r, g, b int, ok bool) {
|
||
if len(s) != 7 || s[0] != '#' {
|
||
return 0, 0, 0, false
|
||
}
|
||
if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
|
||
return 0, 0, 0, false
|
||
}
|
||
return r, g, b, true
|
||
}
|