306 lines
9.0 KiB
Go
306 lines
9.0 KiB
Go
package webui
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Pure geometry + formatting for chart.go — the Go twins of the helpers in
|
|
// jsruntime/uikit/Chart.tsx, producing the same SVG path/rect/text markup.
|
|
|
|
// num formats a coordinate compactly (2 decimals, trailing zeros trimmed) for a path
|
|
// string — 44.57 not 44.571428571428.
|
|
func num(f float64) string {
|
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
|
return "0"
|
|
}
|
|
s := strconv.FormatFloat(f, 'f', 2, 64)
|
|
if strings.ContainsRune(s, '.') {
|
|
s = strings.TrimRight(s, "0")
|
|
s = strings.TrimRight(s, ".")
|
|
}
|
|
return s
|
|
}
|
|
|
|
var svgEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
|
|
|
// svgEsc escapes text/attribute content going into the Raw SVG string — labels and
|
|
// colours can be untrusted (a series name from data, a "<").
|
|
func svgEsc(s string) string { return svgEscaper.Replace(s) }
|
|
|
|
// groupNum is the default value format: up to 2 decimals, thousands grouped.
|
|
func groupNum(v float64) string {
|
|
neg := v < 0
|
|
s := strconv.FormatFloat(math.Abs(v), 'f', 2, 64)
|
|
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
|
intPart, frac := s, ""
|
|
if i := strings.IndexByte(s, '.'); i >= 0 {
|
|
intPart, frac = s[:i], s[i:]
|
|
}
|
|
if n := len(intPart); n > 3 {
|
|
var b strings.Builder
|
|
pre := n % 3
|
|
if pre > 0 {
|
|
b.WriteString(intPart[:pre])
|
|
b.WriteByte(',')
|
|
}
|
|
for i := pre; i < n; i += 3 {
|
|
b.WriteString(intPart[i : i+3])
|
|
if i+3 < n {
|
|
b.WriteByte(',')
|
|
}
|
|
}
|
|
intPart = b.String()
|
|
}
|
|
out := intPart + frac
|
|
if neg && out != "0" {
|
|
out = "-" + out
|
|
}
|
|
return out
|
|
}
|
|
|
|
func roundTo(v float64, n int) float64 {
|
|
p := math.Pow(10, float64(n))
|
|
return math.Round(v*p) / p
|
|
}
|
|
|
|
// niceScale: rounded min/max plus recognisable tick values (0, 20, 40 …).
|
|
func niceScale(mn, mx float64, maxTicks int) (float64, float64, []float64) {
|
|
if math.IsInf(mn, 0) || math.IsInf(mx, 0) || math.IsNaN(mn) || math.IsNaN(mx) || mn == mx {
|
|
v := mx
|
|
if math.IsInf(v, 0) || math.IsNaN(v) {
|
|
v = 0
|
|
}
|
|
mn = math.Min(0, v)
|
|
if v == mn {
|
|
mx = mn + 1
|
|
} else {
|
|
mx = math.Max(0, v)
|
|
}
|
|
}
|
|
niceNum := func(rng float64, round bool) float64 {
|
|
if rng <= 0 {
|
|
rng = 1
|
|
}
|
|
exp := math.Floor(math.Log10(rng))
|
|
frac := rng / math.Pow(10, exp)
|
|
var nf float64
|
|
if round {
|
|
switch {
|
|
case frac < 1.5:
|
|
nf = 1
|
|
case frac < 3:
|
|
nf = 2
|
|
case frac < 7:
|
|
nf = 5
|
|
default:
|
|
nf = 10
|
|
}
|
|
} else {
|
|
switch {
|
|
case frac <= 1:
|
|
nf = 1
|
|
case frac <= 2:
|
|
nf = 2
|
|
case frac <= 5:
|
|
nf = 5
|
|
default:
|
|
nf = 10
|
|
}
|
|
}
|
|
return nf * math.Pow(10, exp)
|
|
}
|
|
step := niceNum((mx-mn)/math.Max(1, float64(maxTicks-1)), true)
|
|
niceMin := math.Floor(mn/step) * step
|
|
niceMax := math.Ceil(mx/step) * step
|
|
decimals := int(math.Max(0, -math.Floor(math.Log10(step))))
|
|
var ticks []float64
|
|
for v := niceMin; v <= niceMax+step*0.5; v += step {
|
|
ticks = append(ticks, roundTo(v, decimals+2))
|
|
}
|
|
return niceMin, niceMax, ticks
|
|
}
|
|
|
|
// roundRectPath: a rectangle with the two corners on `side` rounded (the data-end), the
|
|
// rest square.
|
|
func roundRectPath(x, y, w, h, r float64, side string) string {
|
|
rr := math.Max(0, math.Min(math.Min(r, w/2), h/2))
|
|
var tl, tr, br, bl float64
|
|
switch side {
|
|
case "top":
|
|
tl, tr = rr, rr
|
|
case "bottom":
|
|
bl, br = rr, rr
|
|
case "left":
|
|
tl, bl = rr, rr
|
|
case "right":
|
|
tr, br = rr, rr
|
|
}
|
|
return "M" + num(x+tl) + "," + num(y) +
|
|
" L" + num(x+w-tr) + "," + num(y) + " Q" + num(x+w) + "," + num(y) + " " + num(x+w) + "," + num(y+tr) +
|
|
" L" + num(x+w) + "," + num(y+h-br) + " Q" + num(x+w) + "," + num(y+h) + " " + num(x+w-br) + "," + num(y+h) +
|
|
" L" + num(x+bl) + "," + num(y+h) + " Q" + num(x) + "," + num(y+h) + " " + num(x) + "," + num(y+h-bl) +
|
|
" L" + num(x) + "," + num(y+tl) + " Q" + num(x) + "," + num(y) + " " + num(x+tl) + "," + num(y) + " Z"
|
|
}
|
|
|
|
// bar3D: a bar extruded up-and-right by (dx, dy) — a darkened right face, a lightened top
|
|
// face, then the front. Flat overlays instead of colour maths on a CSS variable.
|
|
func bar3D(x, y, w, h float64, color string, op, dx, dy float64) string {
|
|
top := "M" + num(x) + "," + num(y) + " L" + num(x+dx) + "," + num(y-dy) + " L" + num(x+w+dx) + "," + num(y-dy) + " L" + num(x+w) + "," + num(y) + " Z"
|
|
right := "M" + num(x+w) + "," + num(y) + " L" + num(x+w+dx) + "," + num(y-dy) + " L" + num(x+w+dx) + "," + num(y+h-dy) + " L" + num(x+w) + "," + num(y+h) + " Z"
|
|
return `<path d="` + right + `" fill="` + color + `" fill-opacity="` + num(op) + `"/><path d="` + right + `" fill="#000" fill-opacity="` + num(0.24*op) + `"/>` +
|
|
`<path d="` + top + `" fill="` + color + `" fill-opacity="` + num(op) + `"/><path d="` + top + `" fill="#fff" fill-opacity="` + num(0.2*op) + `"/>` +
|
|
`<rect x="` + num(x) + `" y="` + num(y) + `" width="` + num(w) + `" height="` + num(h) + `" fill="` + color + `" fill-opacity="` + num(op) + `"/>`
|
|
}
|
|
|
|
func linePathD(pts [][2]float64) string {
|
|
if len(pts) == 0 {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
for i, p := range pts {
|
|
if i == 0 {
|
|
b.WriteByte('M')
|
|
} else {
|
|
b.WriteString(" L")
|
|
}
|
|
b.WriteString(num(p[0]) + "," + num(p[1]))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func monoSign(x float64) float64 {
|
|
if x < 0 {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// monoTangent is the interior tangent for monotone-cubic interpolation (d3's
|
|
// curveMonotoneX): the lesser of the two neighbouring secant slopes, and zero at a local
|
|
// extremum. Capping the tangent is what stops a segment bulging past its endpoints, so an
|
|
// area fill can't dip below a value-0 point into negative space the way a Catmull-Rom
|
|
// overshoot does.
|
|
func monoTangent(x0, y0, x1, y1, x2, y2 float64) float64 {
|
|
h0, h1 := x1-x0, x2-x1
|
|
var s0, s1 float64
|
|
if h0 != 0 {
|
|
s0 = (y1 - y0) / h0
|
|
}
|
|
if h1 != 0 {
|
|
s1 = (y2 - y1) / h1
|
|
}
|
|
p := (s0*h1 + s1*h0) / (h0 + h1)
|
|
m := (monoSign(s0) + monoSign(s1)) * math.Min(math.Min(math.Abs(s0), math.Abs(s1)), 0.5*math.Abs(p))
|
|
if math.IsNaN(m) || math.IsInf(m, 0) {
|
|
return 0
|
|
}
|
|
return m
|
|
}
|
|
|
|
// monoEndTangent is the endpoint tangent (d3 slope2): a parabola-end estimate constrained
|
|
// by the adjacent interior tangent t, so the boundary segments don't overshoot either.
|
|
func monoEndTangent(x0, y0, x1, y1, t float64) float64 {
|
|
h := x1 - x0
|
|
if h == 0 {
|
|
return t
|
|
}
|
|
return (3*(y1-y0)/h - t) / 2
|
|
}
|
|
|
|
// smoothPathD: monotone cubic through every point, emitted as cubic beziers. A monotone
|
|
// interpolant never overshoots its data, so the smoothed curve stays within the value
|
|
// range of each pair of adjacent points.
|
|
func smoothPathD(pts [][2]float64) string {
|
|
n := len(pts)
|
|
if n < 3 {
|
|
return linePathD(pts)
|
|
}
|
|
m := make([]float64, n)
|
|
for i := 1; i < n-1; i++ {
|
|
m[i] = monoTangent(pts[i-1][0], pts[i-1][1], pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1])
|
|
}
|
|
m[0] = monoEndTangent(pts[0][0], pts[0][1], pts[1][0], pts[1][1], m[1])
|
|
m[n-1] = monoEndTangent(pts[n-2][0], pts[n-2][1], pts[n-1][0], pts[n-1][1], m[n-2])
|
|
var b strings.Builder
|
|
b.WriteString("M" + num(pts[0][0]) + "," + num(pts[0][1]))
|
|
for i := 0; i < n-1; i++ {
|
|
dx := (pts[i+1][0] - pts[i][0]) / 3
|
|
c1x, c1y := pts[i][0]+dx, pts[i][1]+dx*m[i]
|
|
c2x, c2y := pts[i+1][0]-dx, pts[i+1][1]-dx*m[i+1]
|
|
b.WriteString(" C" + num(c1x) + "," + num(c1y) + " " + num(c2x) + "," + num(c2y) + " " + num(pts[i+1][0]) + "," + num(pts[i+1][1]))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// tiltPoint: a point on a circle tilted about its horizontal axis by k (k=1 upright).
|
|
func tiltPoint(cx, cy, r, deg, k float64) (float64, float64) {
|
|
a := (deg - 90) * math.Pi / 180
|
|
return cx + r*math.Cos(a), cy + k*r*math.Sin(a)
|
|
}
|
|
|
|
// slicePathD: one pie/donut slice a0→a1 degrees, tilted by k. Elliptical arcs make the
|
|
// tilt exact.
|
|
func slicePathD(cx, cy, rOut, rIn, a0, a1, k float64) string {
|
|
large := "0"
|
|
if a1-a0 > 180 {
|
|
large = "1"
|
|
}
|
|
ox0, oy0 := tiltPoint(cx, cy, rOut, a0, k)
|
|
ox1, oy1 := tiltPoint(cx, cy, rOut, a1, k)
|
|
ryO := k * rOut
|
|
if rIn <= 0 {
|
|
return "M" + num(cx) + "," + num(cy) + " L" + num(ox0) + "," + num(oy0) +
|
|
" A" + num(rOut) + "," + num(ryO) + " 0 " + large + " 1 " + num(ox1) + "," + num(oy1) + " Z"
|
|
}
|
|
ix1, iy1 := tiltPoint(cx, cy, rIn, a1, k)
|
|
ix0, iy0 := tiltPoint(cx, cy, rIn, a0, k)
|
|
ryI := k * rIn
|
|
return "M" + num(ox0) + "," + num(oy0) +
|
|
" A" + num(rOut) + "," + num(ryO) + " 0 " + large + " 1 " + num(ox1) + "," + num(oy1) +
|
|
" L" + num(ix1) + "," + num(iy1) +
|
|
" A" + num(rIn) + "," + num(ryI) + " 0 " + large + " 0 " + num(ix0) + "," + num(iy0) + " Z"
|
|
}
|
|
|
|
// ── small numeric helpers ─────────────────────────────────────────────────────────
|
|
|
|
func maxf(a, b float64) float64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
func maxi(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
func clampf(v, lo, hi float64) float64 { return math.Min(hi, math.Max(lo, v)) }
|
|
func clampi(v, lo, hi int) int {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|
|
func maxLen(ss []string) int {
|
|
m := 0
|
|
for _, s := range ss {
|
|
if len(s) > m {
|
|
m = len(s)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
func fmtAll(p ChartProps, ticks []float64) []string {
|
|
out := make([]string, len(ticks))
|
|
for i, t := range ticks {
|
|
out[i] = chartFmt(p, t)
|
|
}
|
|
return out
|
|
}
|