feat(card): stack heatmap into two halves; unify font-size vocabulary (#23)

## Heatmap — two halves, 8x8 cells

The single-row 53-week layout could never hold square cells larger than
4x4 inside a 340 px card — cramped. Split the year in half (ceil(weeks/2)
on top, floor on bottom) and each half is ~27 weeks wide, freeing the
cells to be 8x8 (4x the area) while keeping comfortable left (30 px)
and right (~67 px) gutters. Grid:

  topPadA = 45, half = 7*9 - 1 = 62 tall
  halfGap = 13
  topPadB = 120, same 62 tall
  grid bottom at y = 182, 18 px frame margin

Year still reads top-to-bottom left-to-right, just with one extra line
break at the midpoint. Dropped the separate Less/More legend — at 8x8
the intensity gradient is self-explanatory, and the removal buys the
vertical space the new layout needs.

Refactored into a helper `renderHeatmapHalf` so the two halves share a
single code path (labels + month markers + cells).

## Font-size vocabulary

Four named constants in svg.go: fontBody=12, fontLabel=11, fontAxis=10,
fontBigNum=28. Every card's existing literals already sit on this
ladder except the heatmap, which was using 9 for weekday/month labels.
Heatmap now routes through fontAxis so the whole gallery shares one
size scale.

Other cards' literals weren't rewritten to reference the constants
(pure churn for no behavior change); the constants give future cards
the vocabulary and the design-guidelines the schema.
This commit is contained in:
2026-04-19 11:39:50 +07:00
committed by GitHub
parent 672bfe9d8d
commit 70bcbd43f2
3 changed files with 73 additions and 67 deletions
+3 -3
View File
@@ -77,9 +77,9 @@ When there's **exactly one slice** (one language at 100%), the renderer emits tw
| Metric | Value |
| --- | --- |
| Grid | 7 rows × 53 columns (Sunday → Saturday, oldest week → newest) |
| Cell size | 4 × 4 px square, 1 px gap. 4 is the largest square that fits with breathing room on both sides (`leftPad 30 + 53 × 5 = 295 px`, 45 px right gutter). 5 × 5 with a gap overflows; 5 × 5 touching cells loses visible separation; rectangular cells look stretched. Card has vertical headroom to spare — we accept that in exchange for a clean square grid |
| Grid y-range | `topPad(70) .. 70 + 7 × 5 = 105 px` |
| Layout | **Two stacked halves** of ~27 weeks each. One-row 53-week layout forces cells down to 4 × 4 to fit in 340 px; splitting in half lets each half be 27 weeks wide at **8 × 8 square cells** — 4× the cell area. Year still reads top-to-bottom, left-to-right. |
| Cell size | 8 × 8 px square, 1 px gap |
| Grid geometry | `leftPad 30`, `topPadA 45` (top half), `halfGap 13`, `topPadB 120`. Each half is 7 × 9 1 = 62 px tall. Grid bottom at y=182 leaves 18 px for the frame border. |
| Cell colour | 5-bucket ramp `mixHex(Background, Accent, k/4)` for `k ∈ 0..4` — no dedicated ramp field on the theme schema |
| Weekday labels | Mon / Wed / Fri only, right-anchored in the `leftPad` gutter |
| Month labels | Printed above the first week where a 1st-of-month day falls; skipped when `x > width 20` so `Dec` / `Apr` can't spill past the frame |
+54 -59
View File
@@ -17,26 +17,25 @@ func (contributionsHeatmapCard) SVG(p *github.Profile, t theme.Theme) ([]byte, e
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.
//
// Cells are square. 53 weeks at (cellSize + gap) = 5 px per column fills
// 265 px, which leaves comfortable left (30) and right (45) gutters inside
// the 340 px card. 6 × 6 cells would overflow; 5 × 5 cells with a gap push
// back to the right edge. 4 × 4 is the largest square that keeps breathing
// room on both sides. The resulting grid is short (35 px tall) — the card
// has lots of vertical headroom — but short beats awkwardly stretched.
// renderHeatmap draws the 53-week contribution calendar as two stacked
// halves of ~27 weeks each. A single-row version has to shrink cells to
// 4×4 to fit 53 weeks inside the 340 px width; splitting the year into two
// halves lets each half be 27 weeks wide at 8×8 cells — 4× the cell area
// and distinctly more readable, while the year still reads top-to-bottom
// left-to-right. Cell color mixes theme.Background with theme.Accent in
// four intensity buckets so every palette inherits a usable heatmap.
func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme) []byte {
const (
width = 340
height = 200
cellSize = 4 // square
cellSize = 8
cellGap = 1
leftPad = 30
topPad = 70
topPadA = 45 // top half origin (month labels land at topPadA - 4)
halfGap = 13 // vertical space between the two halves
)
halfH := 7*(cellSize+cellGap) - cellGap // 7 rows of cells occupy this many px
topPadB := topPadA + halfH + halfGap
var b strings.Builder
b.WriteString(header(width, height, t.Background, t.Stroke, t.StrokeOpacity, t.Title, title))
@@ -51,8 +50,6 @@ func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme)
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),
@@ -62,78 +59,76 @@ func renderHeatmap(title string, days []github.DailyContribution, t theme.Theme)
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.
// Split the year at the week boundary nearest the middle — the top half
// gets the ceiling so the first half holds ≥ the second when weeks is odd.
mid := (weeks + 1) / 2
halves := [2]struct {
startWeek int
endWeek int
topPad int
}{
{0, mid, topPadA},
{mid, weeks, topPadB},
}
for _, h := range halves {
renderHeatmapHalf(&b, cells, h.startWeek, h.endWeek, h.topPad, leftPad, cellSize, cellGap, ramp, buckets, t)
}
b.WriteString(footer)
return []byte(b.String())
}
// renderHeatmapHalf draws one half of the heatmap: weekday labels on the
// left, month labels above, and the 7×(endWeek-startWeek) grid itself.
func renderHeatmapHalf(b *strings.Builder, cells []github.DailyContribution, startWeek, endWeek, topPad, leftPad, cellSize, cellGap int, ramp [5]string, buckets [4]int, t theme.Theme) {
// Weekday labels (Mon/Wed/Fri) anchored to the right of the gutter.
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)
fmt.Fprintf(b, `
<text x="%d" y="%d" font-size="%d" fill="%s" text-anchor="end">%s</text>`,
leftPad-4, y, fontAxis, 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.
// Labels within ~20 px of the right edge are dropped so a trailing "Dec"
// or "Apr" can't extend past the card frame.
const monthLabelMaxX = width - 20
// Month labels printed the first time each month's 1st-of-the-month day
// appears in a week column within this half. Labels that would land
// within ~20 px of the right edge are skipped.
monthLabelMaxX := 340 - 20
lastMonth := time.Month(0)
for w := 0; w < weeks; w++ {
for w := startWeek; w < endWeek; 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 {
if first.Day() > 7 || first.Month() == lastMonth {
continue
}
lastMonth = first.Month()
x := leftPad + w*(cellSize+cellGap)
x := leftPad + (w-startWeek)*(cellSize+cellGap)
if x > monthLabelMaxX {
continue
}
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="9" fill="%s">%s</text>`,
x, topPad-4, t.Muted, first.Month().String()[:3])
fmt.Fprintf(b, `
<text x="%d" y="%d" font-size="%d" fill="%s">%s</text>`,
x, topPad-4, fontAxis, t.Muted, first.Month().String()[:3])
}
// Cells.
for w := 0; w < weeks; w++ {
// Cells for this half.
for w := startWeek; w < endWeek; w++ {
for d := 0; d < 7; d++ {
cell := cells[w*7+d]
if cell.Date.IsZero() {
continue // padding slot before the first real day
continue
}
fill := ramp[bucketFor(cell.Count, buckets)]
x := leftPad + w*(cellSize+cellGap)
x := leftPad + (w-startWeek)*(cellSize+cellGap)
y := topPad + d*(cellSize+cellGap)
fmt.Fprintf(&b, `
fmt.Fprintf(b, `
<rect x="%d" y="%d" width="%d" height="%d" rx="1.5" fill="%s"><title>%s — %d</title></rect>`,
x, y, cellSize, cellSize, fill,
cell.Date.Format("2006-01-02"), cell.Count)
}
}
// Legend at the bottom right uses square swatches so "Less ▢▢▢▢▢ More"
// reads as a classic intensity legend rather than a stretched echo of the
// rectangular data cells.
const legendCell = 8
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="1.5" fill="%s"/>`,
legendX+28+i*(legendCell+2), legendY-legendCell+2, legendCell, legendCell, c)
}
fmt.Fprintf(&b, `
<text x="%d" y="%d" font-size="9" fill="%s">More</text>`,
legendX+28+5*(legendCell+2)+2, legendY, t.Muted)
b.WriteString(footer)
return []byte(b.String())
}
// padToWeekGrid prepends zero-date slots so the returned slice is a clean
+16 -5
View File
@@ -116,11 +116,22 @@ func fitTitleFontSize(title string, width int) int {
// Title-sizing constants are exported to package-private so the unit test
// can reference them without duplicating magic numbers.
const (
titleLeftInset = 20
titleRightSafety = 4
titleMinFont = 11
titleMaxFont = 15
titleCharRatio = 0.6
titleLeftInset = 20
titleRightSafety = 4
titleMinFont = 11
titleMaxFont = 15
titleCharRatio = 0.6
)
// Shared body / label / axis font sizes. Any card adding a new text element
// should pick one of these rather than invent a new size — the dracula demo
// gallery looks noticeably more unified when every card uses the same
// vocabulary of sizes.
const (
fontBody = 12 // primary row text: stats labels, names, stat labels
fontLabel = 11 // legends, chart captions
fontAxis = 10 // tick labels, small secondary text
fontBigNum = 28 // streak column hero numbers
)
const footer = `