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

200 lines
5.8 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.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package template
import (
"fmt"
"strconv"
"strings"
)
// 图表辅助函数。
//
// 全站数据页刻意不引入任何前端图表库:本站历史上为消除一个 1.6KB 的外部 defer
// 脚本就把页脚计时器内联了,再挂一个 60KB+ 的 CDN 依赖并不划算。
// 需要坐标的只有折线(SVG),柱状 / 环形 / 热力图都能用 CSS 直接画。
// 因此这里只把「折线要的坐标」算出来,其余交给模板里的百分比与 grid。
// NumInt 把整数格式化成带千分位分隔的字符串,如 56321 → "56,321"。
func NumInt(v int64) string {
sign := ""
if v < 0 {
sign = "-"
v = -v
}
s := strconv.FormatInt(v, 10)
if len(s) <= 3 {
return sign + s
}
var b strings.Builder
lead := len(s) % 3
if lead > 0 {
b.WriteString(s[:lead])
}
for i := lead; i < len(s); i += 3 {
if b.Len() > 0 {
b.WriteByte(',')
}
b.WriteString(s[i : i+3])
}
return sign + b.String()
}
// PctText 返回 part 占 total 的百分比,保留一位小数(不含 % 号,便于模板拼接)。
func PctText(part, total int64) string {
if total <= 0 {
return "0.0"
}
return strconv.FormatFloat(float64(part)/float64(total)*100, 'f', 1, 64)
}
// Tick 是折线纵轴上的一条刻度。
type Tick struct {
// Y 是 viewBox 内的纵坐标("%.1f"),直接填 <line> 的 y1/y2。
Y string
// TopPct 是同一位置换算成画布高度的百分比,用来定位 SVG 外的 HTML 标签。
TopPct string
// Label 是带千分位的数值文本。
Label string
// Base 标记值为 0 的那条基线,模板里画得实一些。
Base bool
}
// LineChart 是一条折线渲染所需的全部几何量。
//
// 坐标、面积路径、纵轴刻度必须在同一次调用里用同一个上限算出来:分几次算,
// 各自按自己看到的峰值归一化,就会出现「刻度写着峰值 40、线却顶到 30 那一格」
// 这种自相矛盾的图。
type LineChart struct {
HasData bool
// Max 是纵轴上限(向上取整到好看的刻度),不是数据峰值。
Max int64
Points string
Area string
Ticks []Tick
}
// NewLineChart 计算折线、面积与纵轴刻度。
//
// w/h 是 viewBox 尺寸,padTop/padBottom 给顶部留呼吸空间、底部留给轴标签。
//
// 纵轴上限取 niceMax(峰值) 而不是峰值本身:峰值正好压在顶格里时,读者没法判断
// 那是确切的数还是被裁掉的数。取整之后顶部留白固定,三条刻度
// (上限 / 一半 / 0)又都是整数和读数好算的数。
func NewLineChart(values []int64, w, h, padTop, padBottom float64) LineChart {
peak := int64(0)
for _, v := range values {
if v > peak {
peak = v
}
}
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, v := range values {
var x float64
if len(values) == 1 {
x = w / 2
} else {
x = float64(i) * w / float64(len(values)-1)
}
if i > 0 {
b.WriteByte(' ')
}
fmt.Fprintf(&b, "%.1f,%.1f", x, yFor(v))
}
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
}
// 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
}
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 表示没有提问)。
// 用档位而不是连续透明度:连续值在小方块上肉眼分不出来,反而让人怀疑渲染出错。
func HeatLevel(n, max int64) int {
if n <= 0 || max <= 0 {
return 0
}
switch {
case n*4 <= max:
return 1
case n*2 <= max:
return 2
case n*4 <= max*3:
return 3
default:
return 4
}
}