// 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"),直接填 的 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 } }