mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-06-01 02:17:47 +00:00
refactor: match github-profile-summary-cards chart styles
- Productive time is now a 24-hour bar chart with axes and nice tick labels instead of a 7x24 heatmap. Model Productive field reshaped from [7][24]int to [24]int. - Language cards render as donut charts with a left-side legend instead of a stacked bar. Slices beyond top-6 collapse into an "Other" row. - Add niceTicks helper (1/2/5 * 10^k ladder, d3-style) for axis ticks. - Legacy language_bar.go removed.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, `
|
||||
<text x="25" y="90" font-size="13" fill="%s">No data available.</text>`, 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, `
|
||||
<rect x="%d" y="%d" width="%d" height="%d" fill="%s" stroke="%s" stroke-width="1"/>
|
||||
<text x="%d" y="%d" font-size="13" fill="%s">%s %.2f%%</text>`,
|
||||
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, `
|
||||
<path d="M%.2f,%.2f A%.2f,%.2f 0 %d 1 %.2f,%.2f L%.2f,%.2f A%.2f,%.2f 0 %d 0 %.2f,%.2f Z" fill="%s" stroke="%s" stroke-width="1.5"/>`,
|
||||
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
|
||||
}
|
||||
@@ -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, `
|
||||
<text x="25" y="90" font-size="13" fill="%s">No data available.</text>`, t.Muted)
|
||||
b.WriteString(footer)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, s := range stats {
|
||||
total += s.Value
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `
|
||||
<rect x="%d" y="%d" width="%d" height="%d" rx="5" fill="%s"/>
|
||||
<g>`,
|
||||
barX, barY, barW, barH, t.Muted)
|
||||
|
||||
offset := float64(barX)
|
||||
for _, s := range stats {
|
||||
w := float64(barW) * float64(s.Value) / float64(total)
|
||||
fmt.Fprintf(&b, `
|
||||
<rect x="%.2f" y="%d" width="%.2f" height="%d" fill="%s"/>`,
|
||||
offset, barY, w, barH, colorOrAccent(s.Color, t.Accent))
|
||||
offset += w
|
||||
}
|
||||
b.WriteString(`
|
||||
</g>`)
|
||||
|
||||
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, `
|
||||
<circle cx="%d" cy="%d" r="6" fill="%s"/>
|
||||
<text x="%d" y="%d" font-size="13" fill="%s">%s %.2f%%</text>`,
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+57
-46
@@ -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, `
|
||||
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
|
||||
leftAxis, topPad, leftAxis, topPad+chartH, t.Muted)
|
||||
for _, v := range ticks {
|
||||
y := topPad + chartH - int(float64(chartH)*v/yMax)
|
||||
fmt.Fprintf(&b, `
|
||||
<text x="25" y="%d" font-size="11" fill="%s">%s</text>`,
|
||||
y, t.Muted, d)
|
||||
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>
|
||||
<text x="%d" y="%d" font-size="10" fill="%s" text-anchor="end">%s</text>`,
|
||||
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, `
|
||||
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>`,
|
||||
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, `
|
||||
<text x="%d" y="55" font-size="10" fill="%s">%02dh</text>`,
|
||||
x, t.Muted, h)
|
||||
<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s"/>
|
||||
<text x="%d" y="%d" font-size="10" fill="%s" text-anchor="middle">%d</text>`,
|
||||
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, `
|
||||
<rect x="%d" y="%d" width="%d" height="%d" rx="3" fill="%s" fill-opacity="%.2f"><title>%s %02d:00 — %d commits</title></rect>`,
|
||||
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, `
|
||||
<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="%s"><title>%02d:00 — %d commits</title></rect>`,
|
||||
x, y, barW, barH, t.Accent, h, count)
|
||||
}
|
||||
|
||||
// X-axis caption.
|
||||
fmt.Fprintf(&b, `
|
||||
<text x="%d" y="%d" font-size="11" fill="%s" text-anchor="middle">hour of day</text>`,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user