Files
ghstats/internal/card/axis.go
T
tiennm99andGitHub a3219e2892 fix(card): y-axis last tick must be ≥ data max (chartH overflow) (#11)
When niceTicks picks a step that doesn't divide the data max, the last
returned tick was the highest step multiple ≤ max. Callers used it as
yMax, so any data point > lastTick rendered a bar > chartH that poked
above the chart top into the title area.

Concrete case from the dracula demo: max=625 with step=100 → ticks
[0, 100, 200, 300, 400, 500, 600], yMax=600, bar height for 625 =
110*(625/600) = 114.58 — 4.58 px past the chart top and right against
the card title.

Fix in niceTicks itself: round the top tick UP to the next step multiple
(`last = ceil(max/step) * step`), so 625 yields [..., 600, 700] and the
same 625 bar lands at 110*(625/700) ≈ 98.2 px, with a clean 12 px gap
to the title.

This is the stable answer to title-vs-bar collision: regardless of
which weekday (or year, or month, or hour) holds the peak, the chart
headroom is built into the axis instead of leaned on per-card. The
title auto-shrink from the previous fix still applies — that's for
literal text width, an orthogonal problem.

Add TestNiceTicksCoversMax covering the cases (625, 99, 101, 7, 49,
999, 1001) that would have silently regressed before.
2026-04-19 09:54:28 +07:00

91 lines
2.5 KiB
Go
Raw 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"
"strconv"
)
// niceTicks returns evenly-spaced tick values starting at 0 with a step of
// 1/2/5/10 × 10^k and a tick count roughly targetTicks. The last tick is the
// smallest step multiple ≥ max, so callers can safely use it as yMax without
// data points ever exceeding the chart's height.
//
// Mirrors d3.scaleLinear().nice() / d3.axisLeft().ticks(n) so charts built on
// top look visually consistent with the d3 reference.
//
// Example: niceTicks(625, 5) returns [0, 100, 200, 300, 400, 500, 600, 700]
// — note 700 > 625, so a bar of 625 fits at 625/700 ≈ 89% of chart height
// with a clean gap above it.
func niceTicks(max float64, targetTicks int) []float64 {
if max <= 0 || targetTicks <= 0 {
return []float64{0}
}
rough := max / float64(targetTicks)
exp := math.Pow(10, math.Floor(math.Log10(rough)))
frac := rough / exp
var step float64
switch {
case frac < 1.5:
step = 1 * exp
case frac < 3:
step = 2 * exp
case frac < 7:
step = 5 * exp
default:
step = 10 * exp
}
// Round the top tick up to the next step multiple so the chart's yMax
// always strictly covers the data. Without this, a data point of 625
// against a step of 100 would yield a last tick of 600 — bars for 625
// would render at 104% of chart height and poke into the title area.
last := math.Ceil(max/step) * step
out := []float64{}
for v := 0.0; v <= last+step/1e9; v += step {
out = append(out, v)
}
return out
}
// 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 {
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)
}