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 `` +
`` +
``
}
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()
}
// smoothPathD: Catmull-Rom → cubic bezier through every point.
func smoothPathD(pts [][2]float64) string {
if len(pts) < 3 {
return linePathD(pts)
}
var b strings.Builder
b.WriteString("M" + num(pts[0][0]) + "," + num(pts[0][1]))
for i := 0; i < len(pts)-1; i++ {
p0 := pts[i]
if i > 0 {
p0 = pts[i-1]
}
p1, p2 := pts[i], pts[i+1]
p3 := p2
if i+2 < len(pts) {
p3 = pts[i+2]
}
c1x, c1y := p1[0]+(p2[0]-p0[0])/6, p1[1]+(p2[1]-p0[1])/6
c2x, c2y := p2[0]-(p3[0]-p1[0])/6, p2[1]-(p3[1]-p1[1])/6
b.WriteString(" C" + num(c1x) + "," + num(c1y) + " " + num(c2x) + "," + num(c2y) + " " + num(p2[0]) + "," + num(p2[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
}