Files
tamakyi cbb8908275 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 的穷举检查。
2026-10-24 01:57:36 +08:00

135 lines
4.4 KiB
Go
Raw Permalink 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 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)
}
}