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() } 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 }