From 70bcbd43f225b621ba4b108f8bb35b8df875679c Mon Sep 17 00:00:00 2001 From: Tien Nguyen Minh Date: Sun, 19 Apr 2026 11:39:50 +0700 Subject: [PATCH] feat(card): stack heatmap into two halves; unify font-size vocabulary (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- docs/design-guidelines.md | 6 +- internal/card/contributions_heatmap.go | 113 ++++++++++++------------- internal/card/svg.go | 21 +++-- 3 files changed, 73 insertions(+), 67 deletions(-) diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md index 0995b468..9a034ef5 100644 --- a/docs/design-guidelines.md +++ b/docs/design-guidelines.md @@ -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 | diff --git a/internal/card/contributions_heatmap.go b/internal/card/contributions_heatmap.go index 01e37ba6..ed8614ab 100644 --- a/internal/card/contributions_heatmap.go +++ b/internal/card/contributions_heatmap.go @@ -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, ` - %s`, - leftPad-4, y, t.Muted, label) + fmt.Fprintf(b, ` + %s`, + 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, ` - %s`, - x, topPad-4, t.Muted, first.Month().String()[:3]) + fmt.Fprintf(b, ` + %s`, + 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, ` %s — %d`, 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, ` - Less`, legendX, legendY, t.Muted) - for i, c := range ramp { - fmt.Fprintf(&b, ` - `, - legendX+28+i*(legendCell+2), legendY-legendCell+2, legendCell, legendCell, c) - } - fmt.Fprintf(&b, ` - More`, - 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 diff --git a/internal/card/svg.go b/internal/card/svg.go index 4ac5985d..e832e47b 100644 --- a/internal/card/svg.go +++ b/internal/card/svg.go @@ -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 = `