mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-02 14:20:13 +00:00
fix(card): adapt to profile magnitude — abbreviate ticks, truncate long text (#9)
The stress test caught real overflows from profiles the author doesn't
have:
- Y-axis tick labels were raw integers. A user with 10,000+ yearly
contributions or 1000+ monthly commits would render "10000" / "1000000"
at text-anchor="end" against a ~28 px gutter — the digits spilled
leftward past x=0. formatTick now abbreviates: 999→"999", 1500→"1.5k",
12345→"12k", 1234567→"1.2M". No label exceeds 4 chars, all fit the
gutter for every card that uses niceTicks.
- Profile details dumped Company / Location / Website / title verbatim,
which works for the author (VNG, Ho Chi Minh, miti99.com) but not for
40-char strings. Each row truncates at 40 runes; the title truncates at
34. Uses a rune-aware truncate() helper hoisted out of top-starred-repos
into svg.go so every list-style card can share it.
- Streak date range collapses to a single-year form ("Jan 2 — Dec 31")
when start.Year() == end.Year() and to "YYYY — YYYY" across years. The
previous "Jan 2 — Dec 31, 2025" format at 10 px × 21 chars pushed past
the ~113 px column width.
The TestCardsFitFrame stress test was reading text-anchor and font-size
with a non-greedy regex that missed attributes whose position varied. It
now parses the opening <text> tag as a block and extracts each attribute
with its own regex, so text-anchor="end" / "middle" elements are no
longer false negatives. The check also estimates rendered width
(0.6 × font-size × len) and asserts the implied left/right edges stay in
the frame — catching exactly the class of bug the axis-tick case
represents.
This commit is contained in:
+38
-2
@@ -1,6 +1,7 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
@@ -36,7 +37,42 @@ func niceTicks(max float64, targetTicks int) []float64 {
|
||||
return out
|
||||
}
|
||||
|
||||
// formatTick renders a float tick label. Integer-valued ticks drop decimals.
|
||||
// formatTick renders a float tick label, abbreviating thousands / millions /
|
||||
// billions so every possible y-axis label fits within ≤4 characters. The
|
||||
// leftPad gutter of every chart card is sized for ≤4 chars at 10 px font,
|
||||
// so anything wider would overflow past the card frame for busy profiles
|
||||
// (1000+ monthly commits, 10k+ yearly contributions, etc).
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// 999 -> "999"
|
||||
// 1_000 -> "1k"
|
||||
// 1_500 -> "1.5k"
|
||||
// 12_345 -> "12k"
|
||||
// 1_234_567 -> "1.2M"
|
||||
func formatTick(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
abs := math.Abs(v)
|
||||
if abs < 1000 {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
var div float64
|
||||
var suffix string
|
||||
switch {
|
||||
case abs < 1_000_000:
|
||||
div, suffix = 1000, "k"
|
||||
case abs < 1_000_000_000:
|
||||
div, suffix = 1_000_000, "M"
|
||||
default:
|
||||
div, suffix = 1_000_000_000, "B"
|
||||
}
|
||||
n := v / div
|
||||
// One decimal place only when it matters. 1.5k stays "1.5k", 10k stays
|
||||
// "10k" not "10.0k", 500k stays "500k".
|
||||
if math.Abs(n) >= 10 || n == math.Trunc(n) {
|
||||
return strconv.FormatFloat(n, 'f', 0, 64) + suffix
|
||||
}
|
||||
return fmt.Sprintf("%.1f%s", n, suffix)
|
||||
}
|
||||
|
||||
@@ -199,6 +199,14 @@ func TestCardsFitFrame(t *testing.T) {
|
||||
// be fragile against the Catmull-Rom Bezier output.
|
||||
var attrCoord = regexp.MustCompile(`(?:x|y|x1|y1|x2|y2|cx|cy)="(-?\d+(?:\.\d+)?)"`)
|
||||
|
||||
// textBlock captures the opening <text …> tag attributes and the inner text.
|
||||
// We parse individual attributes with separate regexes so attribute order
|
||||
// doesn't matter.
|
||||
var textBlock = regexp.MustCompile(`<text\s+([^>]*)>([^<]*)</text>`)
|
||||
var attrX = regexp.MustCompile(`\bx="(-?\d+(?:\.\d+)?)"`)
|
||||
var attrAnchor = regexp.MustCompile(`\btext-anchor="([^"]+)"`)
|
||||
var attrFontSize = regexp.MustCompile(`\bfont-size="(\d+)"`)
|
||||
|
||||
func assertInFrame(t *testing.T, name, svg string) {
|
||||
t.Helper()
|
||||
const (
|
||||
@@ -210,7 +218,6 @@ func assertInFrame(t *testing.T, name, svg string) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Distinguish x-ish vs y-ish by the first attr char.
|
||||
isX := strings.HasPrefix(m[0], "x") || strings.HasPrefix(m[0], "cx")
|
||||
limit := float64(maxY)
|
||||
if isX {
|
||||
@@ -220,6 +227,60 @@ func assertInFrame(t *testing.T, name, svg string) {
|
||||
t.Errorf("%s: attribute %q value %v outside frame (limit %v)", name, m[0], v, limit)
|
||||
}
|
||||
}
|
||||
|
||||
// <text> elements: the x attribute is the anchor point, but the string
|
||||
// actually extends outward from it. Right-anchored axis labels are the
|
||||
// classic overflow trap — the attr x stays inside the frame while the
|
||||
// rendered digits spill past x=0. Estimate width conservatively
|
||||
// (0.6 × font-size per char; Segoe UI avg is ~0.55).
|
||||
for _, m := range textBlock.FindAllStringSubmatch(svg, -1) {
|
||||
attrs := m[1]
|
||||
text := m[2]
|
||||
|
||||
xm := attrX.FindStringSubmatch(attrs)
|
||||
if xm == nil {
|
||||
continue
|
||||
}
|
||||
x, err := strconv.ParseFloat(xm[1], 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
anchor := ""
|
||||
if am := attrAnchor.FindStringSubmatch(attrs); am != nil {
|
||||
anchor = am[1]
|
||||
}
|
||||
fontSize := 12.0
|
||||
if fm := attrFontSize.FindStringSubmatch(attrs); fm != nil {
|
||||
if f, err := strconv.ParseFloat(fm[1], 64); err == nil {
|
||||
fontSize = f
|
||||
}
|
||||
}
|
||||
|
||||
width := float64(runeLen(text)) * fontSize * 0.6
|
||||
var left, right float64
|
||||
switch anchor {
|
||||
case "end":
|
||||
left, right = x-width, x
|
||||
case "middle":
|
||||
left, right = x-width/2, x+width/2
|
||||
default:
|
||||
left, right = x, x+width
|
||||
}
|
||||
if left < -2 {
|
||||
t.Errorf("%s: <text>%q at x=%v anchor=%q extends to left=%.1f (outside frame)", name, text, x, anchor, left)
|
||||
}
|
||||
if right > maxX+2 {
|
||||
t.Errorf("%s: <text>%q at x=%v anchor=%q extends to right=%.1f (outside frame)", name, text, x, anchor, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runeLen(s string) int {
|
||||
n := 0
|
||||
for range s {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// adversarialProfile exercises every card against the worst-case inputs a
|
||||
|
||||
@@ -41,6 +41,11 @@ func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
// scale factor to fit 16x16 octicon into iconSize box
|
||||
scale := float64(iconSize) / 16.0
|
||||
|
||||
// Row text starts at rowX+iconSize+8 = 40; the right edge safety margin
|
||||
// is width-10 = 330. Space = 290 px; at 12 px font × 0.6 char width =
|
||||
// 7.2 px/char, so ~40 characters fit. Clamping here means a 200-char
|
||||
// company name can't push the frame open.
|
||||
const rowMaxChars = 40
|
||||
for i, r := range rows {
|
||||
y := rowY0 + i*rowDY
|
||||
// icon glyph: translate to row position, scale down, fill with muted.
|
||||
@@ -48,7 +53,7 @@ func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
<g transform="translate(%d,%.2f) scale(%.3f)" fill="%s">%s</g>
|
||||
<text x="%d" y="%d" font-size="12" fill="%s">%s</text>`,
|
||||
rowX, float64(y-iconSize+2), scale, t.Muted, r.icon,
|
||||
rowX+iconSize+8, y, t.Text, escapeXML(r.value))
|
||||
rowX+iconSize+8, y, t.Text, escapeXML(truncate(r.value, rowMaxChars)))
|
||||
}
|
||||
|
||||
b.WriteString(footer)
|
||||
@@ -56,12 +61,15 @@ func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
}
|
||||
|
||||
// cardTitle mirrors github-profile-summary-cards: "login (Name)" when name is
|
||||
// set, "login" otherwise.
|
||||
// set, "login" otherwise. Clamped to 34 runes — the title renders at 15 px
|
||||
// weight 600, so ~35 chars is the max that reliably fits in 340−20−10=310
|
||||
// px of title row.
|
||||
func cardTitle(p *github.Profile) string {
|
||||
raw := p.Login
|
||||
if p.Name != "" {
|
||||
return p.Login + " (" + p.Name + ")"
|
||||
raw = p.Login + " (" + p.Name + ")"
|
||||
}
|
||||
return p.Login
|
||||
return truncate(raw, 34)
|
||||
}
|
||||
|
||||
func buildProfileRows(p *github.Profile) []profileRow {
|
||||
|
||||
+11
-7
@@ -126,18 +126,22 @@ func activeDaysDetail(active, total int) string {
|
||||
return fmt.Sprintf("of %s total (%d%%)", formatInt(total), pct)
|
||||
}
|
||||
|
||||
// streakRange formats the open/close dates of a streak as "Mon 2 — Wed 11"
|
||||
// when both are present. Returns "" when the streak is zero-length so the
|
||||
// card renders cleanly.
|
||||
// streakRange formats the open/close dates of a streak for the small detail
|
||||
// line. Each column is ~113 px wide at font-size 10 (≤ ~18 chars), so we
|
||||
// aggressively drop year and redundant parts when they'd blow the budget.
|
||||
//
|
||||
// same day -> "Jan 2 2025"
|
||||
// same year -> "Jan 2 — Dec 31"
|
||||
// different -> "2024 — 2026"
|
||||
func streakRange(start, end time.Time) string {
|
||||
if start.IsZero() || end.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if start.Equal(end) {
|
||||
return start.Format("Jan 2, 2006")
|
||||
return start.Format("Jan 2 2006")
|
||||
}
|
||||
if start.Year() == end.Year() {
|
||||
return start.Format("Jan 2") + " — " + end.Format("Jan 2, 2006")
|
||||
if start.Year() != end.Year() {
|
||||
return fmt.Sprintf("%d — %d", start.Year(), end.Year())
|
||||
}
|
||||
return start.Format("Jan 2006") + " — " + end.Format("Jan 2006")
|
||||
return start.Format("Jan 2") + " — " + end.Format("Jan 2")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,19 @@ func escapeXML(s string) string {
|
||||
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
|
||||
|
||||
@@ -59,7 +59,7 @@ func (topStarredReposCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error)
|
||||
if langColor == "" {
|
||||
langColor = t.Accent
|
||||
}
|
||||
name := truncateName(r.Name, nameMax)
|
||||
name := truncate(r.Name, nameMax)
|
||||
|
||||
fmt.Fprintf(&b, `
|
||||
<circle cx="%d" cy="%d" r="4" fill="%s"/>
|
||||
@@ -100,12 +100,3 @@ func ownedNonForkRepos(repos []github.RepoInfo) []github.RepoInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateName trims to n runes and appends an ellipsis. Operates on runes
|
||||
// so a multi-byte name (e.g. emoji) doesn't mid-cut.
|
||||
func truncateName(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.TrimRight(string(r[:n-1]), ".") + "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user