Files
tiennm99 70bcbd43f2 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.
2026-04-19 11:39:50 +07:00

139 lines
4.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package card
import (
"fmt"
"math"
"strings"
"unicode/utf8"
)
// escapeXML replaces the five XML-significant characters so user-controlled
// strings (bio, repo names) can't break the SVG document or inject markup.
func escapeXML(s string) string {
r := strings.NewReplacer(
"&", "&",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
return r.Replace(s)
}
// truncate returns s clamped to at most n runes, appending "…" when the
// input was longer. Operates on runes so multi-byte strings (emoji, CJK)
// don't split mid-codepoint. Used by every row-style card to make sure a
// pathological name / company / location can't push the card's right edge
// out past the 340 px frame.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimRight(string(r[:n-1]), ".") + "…"
}
// formatInt renders n with thousands separators (e.g. 12345 → "12,345").
func formatInt(n int) string {
neg := n < 0
if neg {
n = -n
}
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
if neg {
return "-" + s
}
return s
}
var b strings.Builder
pre := len(s) % 3
if pre > 0 {
b.WriteString(s[:pre])
if len(s) > pre {
b.WriteByte(',')
}
}
for i := pre; i < len(s); i += 3 {
b.WriteString(s[i : i+3])
if i+3 < len(s) {
b.WriteByte(',')
}
}
out := b.String()
if neg {
return "-" + out
}
return out
}
// header returns the opening <svg> tag + background rect + title text. The
// title font-size is auto-shrunk if the string wouldn't fit on one line at
// the default 15 px — productive-time and productive-weekday append the
// timezone label ("UTC+7.00") which can push the title to ~40 chars, past
// the 340 px frame at 15 px. Floor at 11 px so small zones stay readable.
func header(width, height int, bg, stroke string, strokeOpacity float64, titleColor, title string) string {
fontSize := fitTitleFontSize(title, width)
return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" font-family="'Segoe UI', Ubuntu, Sans-Serif">
<rect x="0.5" y="0.5" width="%d" height="%d" rx="6" fill="%s" stroke="%s" stroke-opacity="%.2f"/>
<text x="20" y="30" font-size="%d" font-weight="600" fill="%s">%s</text>`,
width, height, width, height,
width-1, height-1, bg, stroke, strokeOpacity,
fontSize, titleColor, escapeXML(title))
}
// fitTitleFontSize returns the largest integer font size in
// [titleMinFont, titleMaxFont] at which the title still fits in
// width − titleLeftInset − titleRightSafety, using the same 0.6 × fontSize
// char-width estimate as the fit-the-frame test. Computed directly from
// the budget: ideal = budget / (chars × 0.6), floored to an int, clamped
// to the allowed range.
//
// Utilization for realistic dracula titles (width=340, budget=316):
//
// "Stats" (5) → 15 px (14 %)
// "Top Starred Repos" (17) → 15 px (48 %)
// "Most Commit Language (all time)" (32) → 15 px (91 %)
// "Commits by Hour (last year, UTC+7.00)" (37) → 14 px (98 %)
// "Commits by Weekday (last year, UTC+7.00)" (40) → 13 px (99 %)
// "Commits by Weekday (last year, UTC+12.75)" (41) → 12 px (93 %)
func fitTitleFontSize(title string, width int) int {
chars := utf8.RuneCountInString(title)
if chars == 0 {
return titleMaxFont
}
budget := float64(width - titleLeftInset - titleRightSafety)
ideal := int(math.Floor(budget / (float64(chars) * titleCharRatio)))
if ideal > titleMaxFont {
return titleMaxFont
}
if ideal < titleMinFont {
return titleMinFont
}
return ideal
}
// 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
)
// 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 = `
</svg>`