From 9372e099198e0045e221999b77f71e4be4d58b75 Mon Sep 17 00:00:00 2001 From: Nebojsa Date: Tue, 14 Jul 2026 12:47:43 +0200 Subject: [PATCH 1/5] litmus: showcase LineGraph bezier smoothing overshoot Adds a litmus example demonstrating how smooth line rendering overshoots the actual data range on steep slopes (steps, spikes, zeros, uneven x spacing), making values appear incorrect. Dashed MarkerLines mark the data min/max so any curve crossing them is an overshoot artifact. --- .../charts/line-graph/SmoothingOvershoot.js | 156 ++++++++++++++++++ litmus/index.js | 3 +- 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 litmus/features/charts/line-graph/SmoothingOvershoot.js diff --git a/litmus/features/charts/line-graph/SmoothingOvershoot.js b/litmus/features/charts/line-graph/SmoothingOvershoot.js new file mode 100644 index 000000000..ed4387e17 --- /dev/null +++ b/litmus/features/charts/line-graph/SmoothingOvershoot.js @@ -0,0 +1,156 @@ +import { Chart, Gridlines, Legend, LineGraph, Marker, MarkerLine, NumericAxis } from "cx/charts"; +import { Svg } from "cx/svg"; +import { Controller, LabelsLeftLayout, Repeater } from "cx/ui"; +import { Select, Slider, Switch } from "cx/widgets"; + +// Showcases how bezier-based smoothing overshoots the actual data range. +// The smoothed curve dips below the minimum / rises above the maximum of the data, +// which makes it look like the underlying values are incorrect. +// Most visible with steep slopes next to flat segments (steps, spikes, zeros). + +const datasets = { + step: { + label: "Step (flat → jump → flat)", + points: [0, 0, 0, 0, 0, 100, 100, 100, 100, 100].map((y, i) => ({ x: i * 10, y })), + }, + spike: { + label: "Single spike", + points: [10, 10, 10, 10, 200, 10, 10, 10, 10].map((y, i) => ({ x: i * 10, y })), + }, + zeros: { + label: "Sparse data with zeros", + points: [0, 0, 45, 0, 0, 0, 120, 80, 0, 0, 30, 0].map((y, i) => ({ x: i * 10, y })), + }, + plateau: { + label: "Plateaus with steep transitions", + points: [10, 12, 11, 13, 200, 210, 205, 208, 12, 10, 11].map((y, i) => ({ x: i * 10, y })), + }, + unevenX: { + label: "Uneven x spacing + steep slope", + points: [ + { x: 0, y: 20 }, + { x: 5, y: 22 }, + { x: 10, y: 21 }, + { x: 12, y: 180 }, + { x: 60, y: 185 }, + { x: 62, y: 20 }, + { x: 100, y: 22 }, + ], + }, +}; + +class PageController extends Controller { + onInit() { + this.store.init("$page.dataset", "step"); + this.store.init("$page.smooth", true); + this.store.init("$page.smoothingRatio", 0.4); + this.store.init("$page.showRawLine", true); + this.store.init("$page.showMarkers", true); + this.store.init("$page.showBounds", true); + + this.addTrigger( + "on-dataset-change", + ["$page.dataset"], + (name) => { + const points = datasets[name].points; + this.store.set("$page.points", points); + this.store.set("$page.yMin", Math.min(...points.map((p) => p.y))); + this.store.set("$page.yMax", Math.max(...points.map((p) => p.y))); + }, + true, + ); + } +} + +export default ( + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ +
+

+ The smoothed curve should never cross the dashed red lines — those mark the actual minimum and maximum + of the data. With the current bezier smoothing, steep slopes next to flat segments push the curve + outside the data range (overshoot), so the graph appears to show values that don't exist in the data + (e.g. negative values where the data has zeros). +

+
+
+
+); diff --git a/litmus/index.js b/litmus/index.js index 62dc0adc5..2c58cfa2a 100644 --- a/litmus/index.js +++ b/litmus/index.js @@ -139,10 +139,11 @@ import "./index.scss"; //import Demo from "./features/grid/CellEditing"; // import Demo from "./features/charts/PointReducer"; // import Demo from "./features/charts/line-graph/LineGraph"; +import Demo from "./features/charts/line-graph/SmoothingOvershoot"; // import Demo from "./bugs/GridDefaultSortFieldClearableSortIssue"; // import Demo from "./bugs/GridFixedColumnsFixedHeaderColumnsPosition"; // import Demo from "./bugs/GridOnFetchRecords"; -import Demo from "./performance/GridMemoryLeak"; +// import Demo from "./performance/GridMemoryLeak"; // import Demo from "./features/charts/axis/ComplexAxisLabels"; // import Demo from "./bugs/pie-chart-active-bind"; let store = (window.store = new Store()); From 3446ac74f2a3f7c89e168b5f7d194053a8cf949a Mon Sep 17 00:00:00 2001 From: Nebojsa Date: Tue, 14 Jul 2026 14:00:52 +0200 Subject: [PATCH 2/5] litmus: use explicit high-contrast colors for smoothing overshoot lines --- litmus/features/charts/line-graph/SmoothingOvershoot.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/litmus/features/charts/line-graph/SmoothingOvershoot.js b/litmus/features/charts/line-graph/SmoothingOvershoot.js index ed4387e17..518092732 100644 --- a/litmus/features/charts/line-graph/SmoothingOvershoot.js +++ b/litmus/features/charts/line-graph/SmoothingOvershoot.js @@ -90,16 +90,15 @@ export default ( @@ -110,7 +109,7 @@ export default ( y-bind="$record.y" size={5} shape="circle" - colorIndex={8} + style="fill: #333; stroke: #333" /> From 126a341aa99d32e55638b12287d1b9a473d5839b Mon Sep 17 00:00:00 2001 From: Nebojsa Date: Tue, 14 Jul 2026 14:02:12 +0200 Subject: [PATCH 3/5] litmus: add vertical headroom so overshooting curve isn't clipped --- litmus/features/charts/line-graph/SmoothingOvershoot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litmus/features/charts/line-graph/SmoothingOvershoot.js b/litmus/features/charts/line-graph/SmoothingOvershoot.js index 518092732..058f1e8df 100644 --- a/litmus/features/charts/line-graph/SmoothingOvershoot.js +++ b/litmus/features/charts/line-graph/SmoothingOvershoot.js @@ -68,7 +68,7 @@ export default ( Date: Tue, 14 Jul 2026 14:03:01 +0200 Subject: [PATCH 4/5] litmus: bump chart vertical offsets to 100px --- litmus/features/charts/line-graph/SmoothingOvershoot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litmus/features/charts/line-graph/SmoothingOvershoot.js b/litmus/features/charts/line-graph/SmoothingOvershoot.js index 058f1e8df..3ed41300c 100644 --- a/litmus/features/charts/line-graph/SmoothingOvershoot.js +++ b/litmus/features/charts/line-graph/SmoothingOvershoot.js @@ -68,7 +68,7 @@ export default ( Date: Tue, 14 Jul 2026 14:27:00 +0200 Subject: [PATCH 5/5] feat(charts): support smooth=monotone on LineGraph Adds monotone cubic interpolation (Fritsch-Carlson, same algorithm as d3 curveMonotoneX) as an alternative smoothing mode. Unlike the default bezier smoothing, the monotone curve never overshoots the vertical range of the data, so flat segments stay flat and steep transitions cannot produce phantom values. smooth={true} keeps the existing bezier behavior and smoothingRatio continues to apply to it; smooth=monotone ignores the ratio. Works for both line and area rendering. The smoothing-overshoot litmus now renders two identical charts side by side with a smoothing mode selector on each for direct comparison. --- .../charts/line-graph/SmoothingOvershoot.js | 56 +++++++---- packages/cx/src/charts/LineGraph.tsx | 99 ++++++++++++++++--- 2 files changed, 124 insertions(+), 31 deletions(-) diff --git a/litmus/features/charts/line-graph/SmoothingOvershoot.js b/litmus/features/charts/line-graph/SmoothingOvershoot.js index 3ed41300c..e2d5e3bdb 100644 --- a/litmus/features/charts/line-graph/SmoothingOvershoot.js +++ b/litmus/features/charts/line-graph/SmoothingOvershoot.js @@ -3,9 +3,9 @@ import { Svg } from "cx/svg"; import { Controller, LabelsLeftLayout, Repeater } from "cx/ui"; import { Select, Slider, Switch } from "cx/widgets"; -// Showcases how bezier-based smoothing overshoots the actual data range. -// The smoothed curve dips below the minimum / rises above the maximum of the data, -// which makes it look like the underlying values are incorrect. +// Showcases how bezier-based smoothing overshoots the actual data range and +// compares it side by side with monotone cubic interpolation (smooth="monotone") +// which never leaves the vertical bounds of the data. // Most visible with steep slopes next to flat segments (steps, spikes, zeros). const datasets = { @@ -42,8 +42,10 @@ const datasets = { class PageController extends Controller { onInit() { this.store.init("$page.dataset", "step"); - this.store.init("$page.smooth", true); + this.store.init("$page.smooth1", "bezier"); + this.store.init("$page.smooth2", "monotone"); this.store.init("$page.smoothingRatio", 0.4); + this.store.init("$page.showArea", false); this.store.init("$page.showRawLine", true); this.store.init("$page.showMarkers", true); this.store.init("$page.showBounds", true); @@ -62,11 +64,17 @@ class PageController extends Controller { } } -export default ( +const SmoothingChart = ({ smoothBinding }) => ( -
- - +
+
+ +
+ @@ -110,10 +119,22 @@ export default ( size={5} shape="circle" style="fill: #333; stroke: #333" + legend={false} /> +
+ +); + +export default ( + +
+
+ + +
- + @@ -145,9 +165,9 @@ export default (

The smoothed curve should never cross the dashed red lines — those mark the actual minimum and maximum - of the data. With the current bezier smoothing, steep slopes next to flat segments push the curve - outside the data range (overshoot), so the graph appears to show values that don't exist in the data - (e.g. negative values where the data has zeros). + of the data. Bezier smoothing overshoots on steep slopes next to flat segments, so the graph appears to + show values that don't exist in the data. Monotone interpolation (smooth="monotone") stays + within the data bounds.

diff --git a/packages/cx/src/charts/LineGraph.tsx b/packages/cx/src/charts/LineGraph.tsx index 9d5c17bb5..4bba1744f 100644 --- a/packages/cx/src/charts/LineGraph.tsx +++ b/packages/cx/src/charts/LineGraph.tsx @@ -5,7 +5,7 @@ import { isArray } from "../util/isArray"; import { parseStyle } from "../util/parseStyle"; import { Instance } from "../ui/Instance"; import { RenderingContext } from "../ui/RenderingContext"; -import { NumberProp, BooleanProp, StringProp, RecordsProp, StyleProp } from "../ui/Prop"; +import { NumberProp, BooleanProp, StringProp, RecordsProp, StyleProp, Prop } from "../ui/Prop"; import type { ChartRenderingContext } from "./Chart"; import { ClassProp } from "../ui/Prop"; @@ -42,10 +42,14 @@ export interface LineGraphConfig extends WidgetConfig { /** Indicate that values should be stacked on top of the other values. Default value is `false`. */ stacked?: BooleanProp; - /** Set to `true` to enable smooth (curved) line rendering. */ - smooth?: BooleanProp; + /** + * Set to `true` to enable smooth (curved) line rendering using bezier curves. + * Set to `"monotone"` to use monotone cubic interpolation which never overshoots + * the actual data range, i.e. the curve stays within the vertical bounds of the data. + */ + smooth?: Prop; - /** Controls the curvature of smooth lines. Value should be between 0 and 0.4. Default is 0.05. */ + /** Controls the curvature of smooth lines. Value should be between 0 and 0.4. Default is 0.05. Applies only to bezier smoothing. */ smoothingRatio?: NumberProp; /** Name of the horizontal axis. Default value is `x`. */ @@ -127,7 +131,7 @@ export class LineGraph extends Widget { declare legendAction: string; declare legendShape: string; declare stack: string; - declare smooth: boolean; + declare smooth: boolean | "monotone"; declare smoothingRatio: number; constructor(config: LineGraphConfig) { @@ -304,14 +308,17 @@ export class LineGraph extends Widget { let linePath = ""; if (data.line) { lineSpans.forEach((span) => { - span.forEach((p, i) => { - linePath += - i == 0 - ? `M ${p.x} ${p.y}` - : !data.smooth || span.length < 2 - ? `L ${p.x} ${p.y}` - : this.getCurvedPathSegment(p, span, i - 1, i - 2, i - 1, i + 1, r); - }); + if (span.length == 0) return; + linePath += `M ${span[0].x} ${span[0].y}`; + if (data.smooth == "monotone") linePath += this.getMonotoneSpanPath(span, "y"); + else + span.forEach((p, i) => { + if (i == 0) return; + linePath += + !data.smooth || span.length < 2 + ? `L ${p.x} ${p.y}` + : this.getCurvedPathSegment(p, span, i - 1, i - 2, i - 1, i + 1, r); + }); }); line = ( @@ -326,6 +333,15 @@ export class LineGraph extends Widget { if (data.area) { let areaPath = ""; lineSpans.forEach((span) => { + if (data.smooth == "monotone" && span.length >= 2) { + let last = span[span.length - 1]; + areaPath += `M ${span[0].x} ${span[0].y}`; + areaPath += this.getMonotoneSpanPath(span, "y"); + areaPath += `L ${last.x} ${last.y0}`; + areaPath += this.getMonotoneSpanPath(span, "y0", true); + areaPath += "Z"; + return; + } let closePath = ""; span.forEach((p, i) => { let segment = ""; @@ -374,6 +390,63 @@ export class LineGraph extends Widget { ); } + // Fritsch-Carlson monotone cubic interpolation. Tangents are limited so the + // curve between two points never leaves their vertical range (no overshoot). + getMonotoneTangents(span: LinePoint[], yField: "y" | "y0"): number[] { + const n = span.length; + const m: number[] = new Array(n).fill(0); + if (n < 2) return m; + + const secant = (i: number): number => { + const h = span[i + 1].x - span[i].x; + return h != 0 ? (span[i + 1][yField] - span[i][yField]) / h : 0; + }; + + if (n == 2) { + m[0] = m[1] = secant(0); + return m; + } + + for (let i = 1; i < n - 1; i++) { + const h0 = span[i].x - span[i - 1].x; + const h1 = span[i + 1].x - span[i].x; + const s0 = secant(i - 1); + const s1 = secant(i); + const p = (s0 * h1 + s1 * h0) / (h0 + h1); + m[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0; + } + + const hFirst = span[1].x - span[0].x; + m[0] = hFirst != 0 ? (3 * secant(0) - m[1]) / 2 : m[1]; + + const hLast = span[n - 1].x - span[n - 2].x; + m[n - 1] = hLast != 0 ? (3 * secant(n - 2) - m[n - 2]) / 2 : m[n - 2]; + + return m; + } + + // Emits cubic bezier segments for the whole span using monotone tangents. + // Assumes the path cursor is at the first (or last, when reversed) span point. + getMonotoneSpanPath(span: LinePoint[], yField: "y" | "y0", reverse?: boolean): string { + const m = this.getMonotoneTangents(span, yField); + let path = ""; + if (!reverse) + for (let i = 1; i < span.length; i++) { + const p0 = span[i - 1]; + const p1 = span[i]; + const dx = (p1.x - p0.x) / 3; + path += `C ${p0.x + dx} ${p0[yField] + dx * m[i - 1]}, ${p1.x - dx} ${p1[yField] - dx * m[i]}, ${p1.x} ${p1[yField]}`; + } + else + for (let i = span.length - 1; i > 0; i--) { + const p0 = span[i - 1]; + const p1 = span[i]; + const dx = (p1.x - p0.x) / 3; + path += `C ${p1.x - dx} ${p1[yField] - dx * m[i]}, ${p0.x + dx} ${p0[yField] + dx * m[i - 1]}, ${p0.x} ${p0[yField]}`; + } + return path; + } + getCurvedPathSegment( p: LinePoint, points: LinePoint[],