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.
This commit is contained in:
2026-04-19 09:54:28 +07:00
committed by GitHub
parent d7cc272374
commit e0e99b5774
2 changed files with 37 additions and 5 deletions
+17 -5
View File
@@ -6,11 +6,17 @@ import (
"strconv"
)
// niceTicks returns evenly-spaced tick values in [0, max] such that the step
// is a 1/2/5/10 × 10^k number and the tick count is roughly targetTicks.
// 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.
// 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}
@@ -30,8 +36,14 @@ func niceTicks(max float64, targetTicks int) []float64 {
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 <= max+step/1e9; v += step {
for v := 0.0; v <= last+step/1e9; v += step {
out = append(out, v)
}
return out
+20
View File
@@ -158,6 +158,26 @@ func TestFormatInt(t *testing.T) {
}
}
// TestNiceTicksCoversMax guards the key invariant: the last tick returned
// must be ≥ the requested max, or bar-chart cards render bars taller than
// the chart area and poke into the title. Regression case: max=625 step=100
// previously gave ticks stopping at 600 and bars at 625/600 = 104 % of
// chartH.
func TestNiceTicksCoversMax(t *testing.T) {
cases := []float64{625, 99, 101, 1, 7, 49, 999, 1000, 1001}
for _, m := range cases {
ticks := niceTicks(m, 5)
if len(ticks) == 0 {
t.Errorf("niceTicks(%v): empty", m)
continue
}
last := ticks[len(ticks)-1]
if last < m {
t.Errorf("niceTicks(%v): last tick %v < max — bars will overflow", m, last)
}
}
}
func TestEscapeXML(t *testing.T) {
got := escapeXML(`<a & "b" 'c'>`)
want := "&lt;a &amp; &quot;b&quot; &apos;c&apos;&gt;"