From cbb8908275bef70f140d26c744dc59b009141450 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 24 Oct 2026 01:57:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(stats):=20=E8=BF=91=2030=20=E5=A4=A9?= =?UTF-8?q?=E6=8A=98=E7=BA=BF=E8=A1=A5=E7=BA=B5=E8=BD=B4=E5=88=BB=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来这条折线只有横轴日期,纵轴没有任何参照:读者看到峰谷不知道自己 在看 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 的穷举检查。 --- internal/template/chart.go | 144 ++++++++++++++++++++++++-------- internal/template/chart_test.go | 134 +++++++++++++++++++++++++++++ internal/template/template.go | 5 +- templates/stats.html | 77 ++++++++++++++--- 4 files changed, 308 insertions(+), 52 deletions(-) create mode 100644 internal/template/chart_test.go diff --git a/internal/template/chart.go b/internal/template/chart.go index eb2411c..53e2462 100644 --- a/internal/template/chart.go +++ b/internal/template/chart.go @@ -50,64 +50,134 @@ func PctText(part, total int64) string { return strconv.FormatFloat(float64(part)/float64(total)*100, 'f', 1, 64) } -// ratio 把数值序列归一化到 [0,1],空序列或全零时返回全 0(不会出现除零)。 -func ratioOf(values []int64) ([]float64, float64) { - maxV := int64(0) - for _, v := range values { - if v > maxV { - maxV = v - } - } - out := make([]float64, len(values)) - if maxV == 0 { - return out, 0 - } - for i, v := range values { - out[i] = float64(v) / float64(maxV) - } - return out, float64(maxV) +// Tick 是折线纵轴上的一条刻度。 +type Tick struct { + // Y 是 viewBox 内的纵坐标("%.1f"),直接填 的 y1/y2。 + Y string + // TopPct 是同一位置换算成画布高度的百分比,用来定位 SVG 外的 HTML 标签。 + TopPct string + // Label 是带千分位的数值文本。 + Label string + // Base 标记值为 0 的那条基线,模板里画得实一些。 + Base bool } -// Polyline 生成折线图的 points 属性值。 +// LineChart 是一条折线渲染所需的全部几何量。 +// +// 坐标、面积路径、纵轴刻度必须在同一次调用里用同一个上限算出来:分几次算, +// 各自按自己看到的峰值归一化,就会出现「刻度写着峰值 40、线却顶到 30 那一格」 +// 这种自相矛盾的图。 +type LineChart struct { + HasData bool + // Max 是纵轴上限(向上取整到好看的刻度),不是数据峰值。 + Max int64 + Points string + Area string + Ticks []Tick +} + +// NewLineChart 计算折线、面积与纵轴刻度。 // // w/h 是 viewBox 尺寸,padTop/padBottom 给顶部留呼吸空间、底部留给轴标签。 -// 返回 "x1,y1 x2,y2 ...";空数据返回空串(模板里 SVG 折线自然消失)。 -func Polyline(values []int64, w, h, padTop, padBottom float64) string { - n := len(values) - if n == 0 { - return "" +// +// 纵轴上限取 niceMax(峰值) 而不是峰值本身:峰值正好压在顶格里时,读者没法判断 +// 那是确切的数还是被裁掉的数。取整之后顶部留白固定,三条刻度 +// (上限 / 一半 / 0)又都是整数和读数好算的数。 +func NewLineChart(values []int64, w, h, padTop, padBottom float64) LineChart { + peak := int64(0) + for _, v := range values { + if v > peak { + peak = v + } } - ratios, _ := ratioOf(values) + maxV := niceMax(peak) + usable := h - padTop - padBottom if usable < 1 { usable = 1 } + // yFor 把数值映射到纵坐标:满格贴 padTop,0 落在 h-padBottom。 + yFor := func(v int64) float64 { + return padTop + usable - float64(v)/float64(maxV)*usable + } + + c := LineChart{HasData: peak > 0 && len(values) > 0, Max: maxV} + + // 刻度自上而下:上限 / 一半 / 基线。maxV 恒为偶数,一半必是整数。 + for _, v := range []int64{maxV, maxV / 2, 0} { + y := yFor(v) + c.Ticks = append(c.Ticks, Tick{ + Y: strconv.FormatFloat(y, 'f', 1, 64), + TopPct: strconv.FormatFloat(y/h*100, 'f', 3, 64), + Label: NumInt(v), + Base: v == 0, + }) + } + + // 全零或空数据只出轴、不出线:一条贴着基线的直线看起来像「数据都是 0」, + // 而真实情况往往是「这几天确实没人提问」,两者在页面上必须能区分开。 + if !c.HasData { + return c + } + var b strings.Builder - for i, r := range ratios { + for i, v := range values { var x float64 - if n == 1 { + if len(values) == 1 { x = w / 2 } else { - x = float64(i) * w / float64(n-1) + x = float64(i) * w / float64(len(values)-1) } - y := padTop + usable - r*usable if i > 0 { b.WriteByte(' ') } - fmt.Fprintf(&b, "%.1f,%.1f", x, y) + fmt.Fprintf(&b, "%.1f,%.1f", x, yFor(v)) } - return b.String() + c.Points = b.String() + // 面积沿基线闭合,不落到 viewBox 底边:底边以下是留给轴标签的留白, + // 填充溢过去会和基线刻度线错开一截。 + base := strconv.FormatFloat(yFor(0), 'f', 1, 64) + c.Area = "M0," + base + " L" + + strings.ReplaceAll(c.Points, " ", " L") + + " L" + strconv.FormatFloat(w, 'f', 1, 64) + "," + base + " Z" + return c } -// AreaPath 把同一组数值渲染成闭合的面积路径,用于折线下方的浅色填充。 -func AreaPath(values []int64, w, h, padTop, padBottom float64) string { - points := Polyline(values, w, h, padTop, padBottom) - if points == "" { - return "" +// niceMax 把峰值向上取整到好看的刻度上限。 +// +// 两头都有限制:上限必须是偶数,中间那条刻度(上限的一半)才是整数 —— +// 「7.5 次提问」这种刻度只会让人以为统计口径有问题;上限又不能离开峰值太远, +// 否则折线整条压在图上三分之一的位置,看不出起伏。 +// 因此阶梯是 1 / 1.5 / 2 / 3 / 4 / 6 / 8 × 10^n,其中 1.5 只在 mag≥100 时参与: +// 那时 1.5×mag(150、1500)本身和它的一半都是整数,小量级下会算出 15/7.5 这种。 +func niceMax(v int64) int64 { + if v < 2 { + return 2 } - return "M0," + strconv.FormatFloat(h, 'f', 1, 64) + " L" + - strings.ReplaceAll(points, " ", " L") + - " L" + strconv.FormatFloat(w, 'f', 1, 64) + "," + strconv.FormatFloat(h, 'f', 1, 64) + " Z" + mag := int64(1) + for mag*10 <= v { + mag *= 10 + } + + var cands []int64 + if mag == 1 { + // 个位数阶梯只能给偶数,否则中间刻度是 x.5 + cands = []int64{2, 4, 6, 8, 10} + } else { + cands = []int64{mag} + if mag >= 100 { + cands = append(cands, mag*3/2) + } + for _, m := range []int64{2, 3, 4, 6, 8, 10} { + cands = append(cands, m*mag) + } + } + for _, n := range cands { + if n >= v { + return n + } + } + return 10 * mag } // HeatLevel 把热力图的一个格子映射到 0-4 的强度档位(0 表示没有提问)。 diff --git a/internal/template/chart_test.go b/internal/template/chart_test.go new file mode 100644 index 0000000..4fae723 --- /dev/null +++ b/internal/template/chart_test.go @@ -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) + } +} diff --git a/internal/template/template.go b/internal/template/template.go index 1446420..bc5e160 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -232,12 +232,11 @@ func FuncMap() []template.FuncMap { return string(moderation.StrictnessFor(raw)) }, - // 全站数据页的图表辅助:数字格式化 + 折线坐标(见 chart.go)。 + // 全站数据页的图表辅助:数字格式化 + 折线几何(见 chart.go)。 // 站点不引前端图表库,柱状 / 环形 / 热力图都用 CSS 画,只有折线需要坐标。 "NumInt": NumInt, "PctText": PctText, - "Polyline": Polyline, - "AreaPath": AreaPath, + "LineChart": NewLineChart, "HeatLevel": HeatLevel, }} }) diff --git a/templates/stats.html b/templates/stats.html index 2c435da..326cd9e 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -126,11 +126,51 @@ } /* 折线图 */ - .tb-line-wrap { margin-top: 4px; } + /* 左边一列纵轴刻度 + 右边画布:刻度文字不能放进 SVG —— + viewBox 用了 preserveAspectRatio="none" 横向拉伸填满卡片, + 放在里面的文字会跟着被拉扁(窄屏上尤其明显)。 */ + .tb-line-wrap { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 6px; + margin-top: 4px; + } + .tb-line-y { + position: relative; + height: 148px; + font-size: var(--tb-fs-xs); + font-variant-numeric: tabular-nums; + color: var(--tb-text-muted); + } + /* 刻度全是绝对定位的,撑不开这一列;用一份看不见的「最宽刻度」占位, + 让列宽自己贴合数字长度(上限那个数位数最多),不用写死 em。 */ + .tb-line-ruler { + display: block; + visibility: hidden; + } + .tb-line-tick { + position: absolute; + right: 0; + white-space: nowrap; + /* 刻度值以刻度线为中心对齐,而不是以格子顶部对齐 */ + transform: translateY(-50%); + } .tb-line { display: block; width: 100%; height: 148px; + /* 折线只占上边 148px,刻度标签可能略高于画布,别被裁掉 */ + overflow: visible; + } + .tb-line .tb-grid { + stroke: var(--tb-border); + stroke-width: 1; + stroke-dasharray: 3 5; + /* 同折线:viewBox 被拉伸,描边宽度必须锁像素,否则横向会被算成几倍粗 */ + vector-effect: non-scaling-stroke; + } + .tb-line .tb-grid.is-base { + stroke-dasharray: none; } .tb-line .tb-line-area { /* 比 --tb-primary-weak(0.10)略深一档:折线下方的面积要能看出「有」,但不能抢走线条本身 */ @@ -429,19 +469,32 @@

{{t $.LangCode "stats.trend.dailyTitle"}}

{{t $.LangCode "stats.trend.dailyNote"}}

+ {{ $line := LineChart .DailyValues 640 148 12 10 }}
- - - - - {{ if eq (len .Daily) 30 }} -
- {{ (index .Daily 0).Label }} - {{ (index .Daily 15).Label }} - {{ (index .Daily 29).Label }} + + +
+ + {{ range $line.Ticks }} + + {{ end }} + {{ if $line.HasData }} + + + {{ end }} + + {{ if eq (len .Daily) 30 }} +
+ {{ (index .Daily 0).Label }} + {{ (index .Daily 15).Label }} + {{ (index .Daily 29).Label }} +
+ {{ end }}
- {{ end }}