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:
+107
-37
@@ -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"),直接填 <line> 的 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 表示没有提问)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
})
|
||||
|
||||
+56
-3
@@ -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,11 +469,23 @@
|
||||
<div class="tb-card">
|
||||
<h3 class="tb-card-title">{{t $.LangCode "stats.trend.dailyTitle"}}</h3>
|
||||
<p class="tb-card-note">{{t $.LangCode "stats.trend.dailyNote"}}</p>
|
||||
{{ $line := LineChart .DailyValues 640 148 12 10 }}
|
||||
<div class="tb-line-wrap">
|
||||
<!-- 纵轴刻度:SVG 横向被拉伸,文字只能留在外面用 HTML 定位 -->
|
||||
<div class="tb-line-y" aria-hidden="true">
|
||||
<span class="tb-line-ruler">{{ (index $line.Ticks 0).Label }}</span>
|
||||
{{ range $line.Ticks }}<span class="tb-line-tick" style="top:{{ .TopPct }}%">{{ .Label }}</span>{{ end }}
|
||||
</div>
|
||||
<div class="tb-line-plot">
|
||||
<svg class="tb-line" viewBox="0 0 640 148" preserveAspectRatio="none" role="img"
|
||||
aria-label="{{t $.LangCode "stats.trend.dailyTitle"}}">
|
||||
<path class="tb-line-area" d="{{ AreaPath .DailyValues 640 148 12 10 }}"></path>
|
||||
<polyline class="tb-line-path" points="{{ Polyline .DailyValues 640 148 12 10 }}"></polyline>
|
||||
{{ range $line.Ticks }}
|
||||
<line class="tb-grid{{ if .Base }} is-base{{ end }}" x1="0" x2="640" y1="{{ .Y }}" y2="{{ .Y }}"></line>
|
||||
{{ end }}
|
||||
{{ if $line.HasData }}
|
||||
<path class="tb-line-area" d="{{ $line.Area }}"></path>
|
||||
<polyline class="tb-line-path" points="{{ $line.Points }}"></polyline>
|
||||
{{ end }}
|
||||
</svg>
|
||||
{{ if eq (len .Daily) 30 }}
|
||||
<div class="tb-axis">
|
||||
@@ -444,6 +496,7 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 近 12 个月 -->
|
||||
<div class="tb-card">
|
||||
|
||||
Reference in New Issue
Block a user