feat(stats): 近 30 天折线补纵轴刻度

原来这条折线只有横轴日期,纵轴没有任何参照:读者看到峰谷不知道自己
在看 3 次还是 300 次的起伏。现在补上三条刻度(上限 / 一半 / 0)加对应
的横向虚线。

纵轴上限取 niceMax(峰值),不是峰值本身。峰值正好压在顶格里时,读者
分不清那到底是确切的数还是被裁掉的数;向上取整到 1/2/1.5/3/4/6/8×10^n
之后顶部留白固定,三条刻度又都是整数和好读的数(1.5 倍只在量级≥100
时参与,否则算出来是 7.5 这种半格)。上限恒为偶数,中间刻度必然是整数。

刻度文字不放进 SVG:viewBox 用 preserveAspectRatio="none" 横向拉伸填满
卡片,里面的文字会跟着被拉扁,窄屏尤其明显。改成左边一列 HTML 标签 +
横向虚线仍在 SVG 里(描边锁 non-scaling-stroke)。列宽靠一份看不见的
「最宽刻度」占位撑开,不写死 em。

同时把 Polyline/AreaPath 收敛成单个 NewLineChart:坐标、面积、刻度必须
共用同一个上限,分两次算就会各按自己看到的峰值归一化,出现刻度和线错开
的图。面积路径改为沿基线闭合,不再漏进轴标签的留白区。

新增 chart_test.go 锁住三条不变式:中间刻度必为整数、上限不离开峰值一倍、
刻度/折线/面积共用上限;另有 0..2000 的穷举检查。
This commit is contained in:
tamakyi
2026-10-24 01:57:36 +08:00
parent 0226956feb
commit cbb8908275
4 changed files with 308 additions and 52 deletions
+134
View File
@@ -0,0 +1,134 @@
package template
import (
"testing"
)
// 纵轴刻度的三条不变式。它们坏了图表不会报错,只会「看起来有点怪」,
// 所以必须由测试兜住:
// 1. 中间刻度必须是整数(否则出现 7.5 次提问);
// 2. 上限不能绑死在峰值上,也不能离开峰值太远(折线悬在三分之一高度等于没有起伏);
// 3. 刻度、折线、面积必须共用同一个上限,三者不能各算各的。
func TestNiceMax(t *testing.T) {
cases := []struct {
peak int64
want int64
}{
{0, 2}, // 空数据也要有一条能画的轴
{1, 2}, // 单个提问:上限 2,不出现 0/0.5/1
{2, 2}, // 峰值正好等于上限,线贴顶但刻度是实数
{3, 4}, // 个位数阶梯只有偶数
{9, 10}, //
{10, 10}, // 量级跳上去之后 10 本身是合法上限
{13, 20}, //
{30, 30}, // 30/2 = 15,整数,可用
{95, 100},
{134, 150}, // 1.5 倍阶梯:134 用 200 会悬在近七成高
{100, 100},
{136, 150},
{160, 200}, // 150 不够了才让给 200
{2148, 3000},
{56321, 60000},
}
for _, c := range cases {
if got := niceMax(c.peak); got != c.want {
t.Errorf("niceMax(%d) = %d, want %d", c.peak, got, c.want)
}
}
}
// TestNiceMaxInvariants 对一批峰值检查上面三条不变式,避免只对着手写的样例调参。
func TestNiceMaxInvariants(t *testing.T) {
for peak := int64(0); peak <= 2000; peak++ {
m := niceMax(peak)
if m < peak {
t.Fatalf("niceMax(%d) = %d,上限小于峰值", peak, m)
}
if m%2 != 0 {
t.Fatalf("niceMax(%d) = %d,中间刻度会是 %d/2", peak, m, m)
}
// 例外只有「峰值 ≤ 2」:这时上限只能是 2。
if peak > 2 && m >= peak*2 {
t.Fatalf("niceMax(%d) = %d,顶部空白超过一倍", peak, m)
}
}
}
// TestNewLineChartTicks 刻度自上而下是 上限 / 一半 / 0,且坐标落在画布内。
func TestNewLineChartTicks(t *testing.T) {
const w, h, padTop, padBottom = 640.0, 148.0, 12.0, 10.0
c := NewLineChart([]int64{134, 80, 0, 41}, w, h, padTop, padBottom)
if !c.HasData {
t.Fatal("有数据却 HasData=false")
}
if c.Max != 150 {
t.Fatalf("Max = %d, want 150", c.Max)
}
if len(c.Ticks) != 3 {
t.Fatalf("刻度数 = %d, want 3", len(c.Ticks))
}
wantLabels := []string{"150", "75", "0"}
for i, want := range wantLabels {
if got := c.Ticks[i].Label; got != want {
t.Errorf("第 %d 条刻度 = %q, want %q", i, got, want)
}
}
if c.Ticks[0].Y != "12.0" {
t.Errorf("上限刻度 y = %s, want 12.0(贴 padTop)", c.Ticks[0].Y)
}
if c.Ticks[2].Y != "138.0" {
t.Errorf("基线刻度 y = %s, want 138.0(h - padBottom)", c.Ticks[2].Y)
}
if !c.Ticks[2].Base || c.Ticks[1].Base {
t.Error("只有值为 0 的那条刻度该标 Base")
}
}
// TestNewLineChartEmpty 全零数据:轴还在,线不画(HasData=false)。
func TestNewLineChartEmpty(t *testing.T) {
c := NewLineChart([]int64{0, 0, 0}, 640, 148, 12, 10)
if c.HasData {
t.Error("全零数据不该 HasData")
}
if c.Max != 2 || len(c.Ticks) != 3 {
t.Errorf("全零数据也要有可画的轴,得到 Max=%d ticks=%d", c.Max, len(c.Ticks))
}
if c.Points != "" || c.Area != "" {
t.Errorf("全零数据不该生成折线:points=%q area=%q", c.Points, c.Area)
}
}
// TestLineChartScalesWithMax 折线的高度必须按 niceMax 而不是峰值归一化,
// 否则「最高的一天」永远顶在图顶,刻度就成了摆设。
func TestLineChartScalesWithMax(t *testing.T) {
const w, h, padTop, padBottom = 640.0, 148.0, 12.0, 10.0
// 峰值 6 = 上限 6:顶满
c := NewLineChart([]int64{3, 6}, w, h, padTop, padBottom)
if c.Max != 6 {
t.Fatalf("Max = %d, want 6", c.Max)
}
if c.Points != "0.0,75.0 640.0,12.0" {
t.Errorf("points = %q, want 0.0,75.0 640.0,12.0", c.Points)
}
// 峰值 75 被抬到上限 80:最高点留出 6% 的顶部空白,而不是贴死图顶
c = NewLineChart([]int64{75}, w, h, padTop, padBottom)
if c.Max != 80 {
t.Fatalf("Max = %d, want 80", c.Max)
}
if c.Points != "320.0,19.9" {
t.Errorf("points = %q, want 320.0,19.9", c.Points)
}
}
// TestAreaPathClosesOnBaseline 面积沿基线闭合:落到 viewBox 底边会漏进轴标签留白,
// 和基线刻度线错开一截。
func TestAreaPathClosesOnBaseline(t *testing.T) {
c := NewLineChart([]int64{10, 20}, 640, 148, 12, 10)
want := "M0,138.0 L0.0,75.0 L640.0,12.0 L640.0,138.0 Z"
if c.Area != want {
t.Errorf("area = %q, want %q", c.Area, want)
}
}