mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
## 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.
139 lines
4.3 KiB
Go
139 lines
4.3 KiB
Go
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(
|
||
"&", "&",
|
||
"<", "<",
|
||
">", ">",
|
||
`"`, """,
|
||
"'", "'",
|
||
)
|
||
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>`
|