原来这条折线只有横轴日期,纵轴没有任何参照:读者看到峰谷不知道自己 在看 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 的穷举检查。
200 lines
5.8 KiB
Go
200 lines
5.8 KiB
Go
// 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
|
||
}
|
||
}
|