From 81f0c0624ec3cc300faa38194311f1ff69aff540 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Thu, 16 Jul 2026 15:21:50 -0400 Subject: [PATCH] update to use curveMonotoneX alg for smothing curves --- go/jsruntime/uikit/Chart.tsx | 49 +++++++++++++++++++------ go/webui/chart_geom.go | 70 ++++++++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/go/jsruntime/uikit/Chart.tsx b/go/jsruntime/uikit/Chart.tsx index 97ff78fc..cb4e55cf 100644 --- a/go/jsruntime/uikit/Chart.tsx +++ b/go/jsruntime/uikit/Chart.tsx @@ -160,19 +160,46 @@ function linePathD(pts: [number, number][]): string { return pts.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "); } -// Catmull-Rom → cubic bezier, so the curve passes through every point (a plain bezier -// smoothing would miss them). +const sign = (x: number) => (x < 0 ? -1 : 1); + +// Interior tangent for monotone-cubic interpolation (d3's curveMonotoneX): the lesser of +// the two neighbouring secant slopes, and zero at a local extremum. This is the whole +// point of the monotone curve — the tangent is capped so a segment can never bulge 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. +function monoTangent(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number): number { + const h0 = x1 - x0, h1 = x2 - x1; + const s0 = h0 !== 0 ? (y1 - y0) / h0 : 0; + const s1 = h1 !== 0 ? (y2 - y1) / h1 : 0; + const p = (s0 * h1 + s1 * h0) / (h0 + h1); + return (sign(s0) + sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0; +} + +// Endpoint tangent (d3 slope2): a parabola-end estimate constrained by the adjacent +// interior tangent t, so the boundary segments don't overshoot either. +function endTangent(x0: number, y0: number, x1: number, y1: number, t: number): number { + const h = x1 - x0; + return h !== 0 ? (3 * (y1 - y0) / h - t) / 2 : t; +} + +// 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. function smoothPathD(pts: [number, number][]): string { - if (pts.length < 3) return linePathD(pts); + const n = pts.length; + if (n < 3) return linePathD(pts); + const m: number[] = new Array(n); + for (let 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] = endTangent(pts[0][0], pts[0][1], pts[1][0], pts[1][1], m[1]); + m[n - 1] = endTangent(pts[n - 2][0], pts[n - 2][1], pts[n - 1][0], pts[n - 1][1], m[n - 2]); let d = `M${pts[0][0]},${pts[0][1]}`; - for (let i = 0; i < pts.length - 1; i++) { - const p0 = pts[i - 1] ?? pts[i]; - const p1 = pts[i]; - const p2 = pts[i + 1]; - const p3 = pts[i + 2] ?? p2; - const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; - const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; - d += ` C${c1x},${c1y} ${c2x},${c2y} ${p2[0]},${p2[1]}`; + for (let i = 0; i < n - 1; i++) { + const dx = (pts[i + 1][0] - pts[i][0]) / 3; + const c1x = pts[i][0] + dx, c1y = pts[i][1] + dx * m[i]; + const c2x = pts[i + 1][0] - dx, c2y = pts[i + 1][1] - dx * m[i + 1]; + d += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1][0]},${pts[i + 1][1]}`; } return d; } diff --git a/go/webui/chart_geom.go b/go/webui/chart_geom.go index e35a3441..a77c55cc 100644 --- a/go/webui/chart_geom.go +++ b/go/webui/chart_geom.go @@ -170,26 +170,66 @@ func linePathD(pts [][2]float64) string { return b.String() } -// smoothPathD: Catmull-Rom → cubic bezier through every point. +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 { - if len(pts) < 3 { + 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 < 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])) + 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() }