From 9372e099198e0045e221999b77f71e4be4d58b75 Mon Sep 17 00:00:00 2001 From: Nebojsa Date: Tue, 14 Jul 2026 12:47:43 +0200 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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[], From 71d146f2a48c7ea702cdb29a45d9b38713d0f63d Mon Sep 17 00:00:00 2001 From: Nebojsa Date: Wed, 29 Jul 2026 14:57:40 +0200 Subject: [PATCH 6/6] feat(charts): smooth line rendering always uses monotone interpolation Keep monotone cubic interpolation as the only smoothing algorithm on LineGraph - smooth=true now renders monotone curves that never overshoot the vertical range of the data. Remove the bezier-based smoothing code and deprecate smoothingRatio, which is now ignored since monotone interpolation has no configurable curvature. Update the litmus examples and homedocs accordingly. --- .../src/examples/charts/LineGraphExample.tsx | 14 -- .../intro/FunctionalComponentChartExample.tsx | 1 - homedocs/src/pages/docs/charts/line-graph.mdx | 11 +- .../features/charts/line-graph/LineGraph.js | 13 -- .../charts/line-graph/SmoothingOvershoot.js | 55 ++------ packages/cx/src/charts/LineGraph.tsx | 129 +++--------------- 6 files changed, 38 insertions(+), 185 deletions(-) diff --git a/homedocs/src/examples/charts/LineGraphExample.tsx b/homedocs/src/examples/charts/LineGraphExample.tsx index 52c816388..d71c8ff14 100644 --- a/homedocs/src/examples/charts/LineGraphExample.tsx +++ b/homedocs/src/examples/charts/LineGraphExample.tsx @@ -25,7 +25,6 @@ interface Model { showArea: boolean; showLine: boolean; smooth: boolean; - smoothingRatio: number; line1: boolean; line2: boolean; } @@ -40,7 +39,6 @@ class PageController extends Controller { this.store.init(m.showArea, true); this.store.init(m.showLine, true); this.store.init(m.smooth, true); - this.store.init(m.smoothingRatio, 0.07); this.addTrigger( "on-count-change", @@ -88,7 +86,6 @@ export default ( line={false} area={m.showArea} smooth={m.smooth} - smoothingRatio={m.smoothingRatio} /> @@ -128,15 +123,6 @@ export default ( - v?.toFixed(2) ?? "")} - />
); diff --git a/homedocs/src/examples/intro/FunctionalComponentChartExample.tsx b/homedocs/src/examples/intro/FunctionalComponentChartExample.tsx index a41a93b4f..3ba6842f8 100644 --- a/homedocs/src/examples/intro/FunctionalComponentChartExample.tsx +++ b/homedocs/src/examples/intro/FunctionalComponentChartExample.tsx @@ -27,7 +27,6 @@ const LineChart = createFunctionalComponent( area={areaStyle != null} areaStyle={areaStyle} smooth - smoothingRatio={0.2} /> diff --git a/homedocs/src/pages/docs/charts/line-graph.mdx b/homedocs/src/pages/docs/charts/line-graph.mdx index 6fe6f2175..724fa4591 100644 --- a/homedocs/src/pages/docs/charts/line-graph.mdx +++ b/homedocs/src/pages/docs/charts/line-graph.mdx @@ -23,7 +23,7 @@ Line charts are commonly used for data trends visualization. The `LineGraph` wid -The example above demonstrates multiple line series with area fill, smooth curves, and a range band (using `y0Field` and `yField` to define upper and lower bounds). Use the controls to adjust the number of data points, toggle area/line visibility, and modify the smoothing effect. +The example above demonstrates multiple line series with area fill, smooth curves, and a range band (using `y0Field` and `yField` to define upper and lower bounds). Use the controls to adjust the number of data points, toggle area/line visibility, and toggle smoothing. ## Configuration @@ -52,10 +52,11 @@ The example above demonstrates multiple line series with area fill, smooth curve ### Smoothing -| Property | Type | Default | Description | -| ---------------- | --------- | ------- | ---------------------------------------------------------------------------------------------- | -| `smooth` | `boolean` | `false` | Set to `true` to draw smoothed lines using cubic Bézier curves | -| `smoothingRatio` | `number` | `0.05` | Controls the intensity of smoothing (0 to 0.4). Higher values create more curved lines | +| Property | Type | Default | Description | +| -------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `smooth` | `boolean` | `false` | Set to `true` to draw smoothed lines. Uses monotone cubic interpolation, so the curve never overshoots the vertical range of the data | + +> The `smoothingRatio` property is deprecated and ignored — the monotone interpolation used for smoothing does not have a configurable curvature. ### Stacking diff --git a/litmus/features/charts/line-graph/LineGraph.js b/litmus/features/charts/line-graph/LineGraph.js index a9e527e32..b4721f643 100644 --- a/litmus/features/charts/line-graph/LineGraph.js +++ b/litmus/features/charts/line-graph/LineGraph.js @@ -9,7 +9,6 @@ class PageController extends Controller { this.store.init("$page.showArea", true); this.store.init("$page.showLine", true); this.store.init("$page.smooth", true); - this.store.init("$page.smoothingRatio", 0.1); this.addTrigger( "on-count-change", @@ -54,7 +53,6 @@ export default ( area-bind="$page.showArea" line={false} smooth-bind="$page.smooth" - smoothingRatio-bind="$page.smoothingRatio" name="Line 1" /> @@ -76,7 +73,6 @@ export default ( area-bind="$page.showArea" line-bind="$page.showLine" smooth-bind="$page.smooth" - smoothingRatio-bind="$page.smoothingRatio" name="Line 2" /> @@ -107,15 +103,6 @@ export default ( -
diff --git a/litmus/features/charts/line-graph/SmoothingOvershoot.js b/litmus/features/charts/line-graph/SmoothingOvershoot.js index e2d5e3bdb..c204509ec 100644 --- a/litmus/features/charts/line-graph/SmoothingOvershoot.js +++ b/litmus/features/charts/line-graph/SmoothingOvershoot.js @@ -1,12 +1,12 @@ -import { Chart, Gridlines, Legend, LineGraph, Marker, MarkerLine, NumericAxis } from "cx/charts"; +import { Chart, Gridlines, 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"; +import { Select, Switch } from "cx/widgets"; -// 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). +// Verifies that smooth line rendering (monotone cubic interpolation) never +// overshoots the actual data range, i.e. the curve stays within the vertical +// bounds of the data. Most sensitive datasets are those with steep slopes next +// to flat segments (steps, spikes, zeros). const datasets = { step: { @@ -42,9 +42,7 @@ const datasets = { class PageController extends Controller { onInit() { this.store.init("$page.dataset", "step"); - this.store.init("$page.smooth1", "bezier"); - this.store.init("$page.smooth2", "monotone"); - this.store.init("$page.smoothingRatio", 0.4); + this.store.init("$page.smooth", true); this.store.init("$page.showArea", false); this.store.init("$page.showRawLine", true); this.store.init("$page.showMarkers", true); @@ -64,16 +62,9 @@ class PageController extends Controller { } } -const SmoothingChart = ({ smoothBinding }) => ( +export default ( -
-
- -
+
( lineStyle="stroke: #0074eb; stroke-width: 2.5" areaStyle="fill: rgba(0, 116, 235, 0.15)" area-bind="$page.showArea" - smooth={{ expr: `{${smoothBinding}} == 'off' ? false : {${smoothBinding}} == 'bezier' ? true : {${smoothBinding}}` }} - smoothingRatio-bind="$page.smoothingRatio" + smooth-bind="$page.smooth" legend={false} /> ( -
- -); - -export default ( - -
-
- - -
- + @@ -165,9 +137,8 @@ export default (

The smoothed curve should never cross the dashed red lines — those mark the actual minimum and maximum - 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. + of the data. Smoothing is based on monotone cubic interpolation, which stays within the data bounds, so + the graph never suggests values that don't exist in the data.

diff --git a/packages/cx/src/charts/LineGraph.tsx b/packages/cx/src/charts/LineGraph.tsx index 4bba1744f..a4b34c3ca 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, Prop } from "../ui/Prop"; +import { NumberProp, BooleanProp, StringProp, RecordsProp, StyleProp } from "../ui/Prop"; import type { ChartRenderingContext } from "./Chart"; import { ClassProp } from "../ui/Prop"; @@ -43,13 +43,13 @@ export interface LineGraphConfig extends WidgetConfig { stacked?: 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. + * Set to `true` to enable smooth (curved) line rendering. Uses monotone cubic + * interpolation which never overshoots the actual data range, i.e. the curve + * stays within the vertical bounds of the data. */ - smooth?: Prop; + smooth?: BooleanProp; - /** Controls the curvature of smooth lines. Value should be between 0 and 0.4. Default is 0.05. Applies only to bezier smoothing. */ + /** @deprecated Smoothing is based on monotone cubic interpolation and its curvature is not configurable. This property is ignored. */ smoothingRatio?: NumberProp; /** Name of the horizontal axis. Default value is `x`. */ @@ -131,8 +131,7 @@ export class LineGraph extends Widget { declare legendAction: string; declare legendShape: string; declare stack: string; - declare smooth: boolean | "monotone"; - declare smoothingRatio: number; + declare smooth: boolean; constructor(config: LineGraphConfig) { super(config); @@ -163,7 +162,6 @@ export class LineGraph extends Widget { stack: undefined, stacked: undefined, smooth: undefined, - smoothingRatio: undefined, }); } @@ -172,11 +170,6 @@ export class LineGraph extends Widget { if (data.name && !data.colorName) data.colorName = data.name; - if (data.smooth && data.smoothingRatio != null) { - if (data.smoothingRatio < 0) data.smoothingRatio = 0; - if (data.smoothingRatio > 0.4) data.smoothingRatio = 0.4; - } - super.prepareData(context, instance); } @@ -303,21 +296,16 @@ export class LineGraph extends Widget { }; let line: React.ReactNode, area: React.ReactNode; - const r = data.smoothingRatio; let linePath = ""; if (data.line) { lineSpans.forEach((span) => { if (span.length == 0) return; linePath += `M ${span[0].x} ${span[0].y}`; - if (data.smooth == "monotone") linePath += this.getMonotoneSpanPath(span, "y"); + if (data.smooth && span.length >= 2) 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); + if (i > 0) linePath += `L ${p.x} ${p.y}`; }); }); @@ -333,43 +321,20 @@ 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}`; + if (span.length == 0) return; + let last = span[span.length - 1]; + areaPath += `M ${span[0].x} ${span[0].y}`; + if (data.smooth && span.length >= 2) { areaPath += this.getMonotoneSpanPath(span, "y"); areaPath += `L ${last.x} ${last.y0}`; areaPath += this.getMonotoneSpanPath(span, "y0", true); - areaPath += "Z"; - return; + } else { + span.forEach((p, i) => { + if (i > 0) areaPath += `L ${p.x} ${p.y}`; + }); + areaPath += `L ${last.x} ${last.y0}`; + for (let i = span.length - 2; i >= 0; i--) areaPath += `L ${span[i].x} ${span[i].y0}`; } - let closePath = ""; - span.forEach((p, i) => { - let segment = ""; - if (i == 0) { - segment = `M ${p.x} ${p.y}`; - - // closing point - closePath = - !data.smooth || span.length < 2 - ? `L ${p.x} ${p.y0}` - : this.getCurvedPathSegment(p, span, i + 1, i + 2, i + 1, i - 1, r, "y0"); - } else { - if (!data.smooth) { - segment = `L ${p.x} ${p.y}`; - closePath = `L ${p.x} ${p.y0}` + closePath; - } else { - segment = this.getCurvedPathSegment(p, span, i - 1, i - 2, i - 1, i + 1, r, "y"); - - // closing point - if (i < span.length - 1) - closePath = this.getCurvedPathSegment(p, span, i + 1, i + 2, i + 1, i - 1, r, "y0") + closePath; - } - } - areaPath += segment; - }); - - areaPath += `L ${span[span.length - 1].x} ${span[span.length - 1].y0}`; - areaPath += closePath; areaPath += "Z"; }); @@ -447,61 +412,6 @@ export class LineGraph extends Widget { return path; } - getCurvedPathSegment( - p: LinePoint, - points: LinePoint[], - i1: number, - i2: number, - j1: number, - j2: number, - r: number, - yField: "y" | "y0" = "y", - ): string { - const [sx, sy] = this.getControlPoint({ cp: points[i1], pp: points[i2], r, np: p, yField }); - const [ex, ey] = this.getControlPoint({ cp: p, pp: points[j1], np: points[j2], r, reverse: true, yField }); - - return `C ${sx} ${sy}, ${ex} ${ey}, ${p.x} ${p[yField]}`; - } - - getControlPoint({ - cp, - pp, - np, - r, - reverse, - yField = "y", - }: { - cp: LinePoint; - pp: LinePoint | undefined; - np: LinePoint | undefined; - r: number; - reverse?: boolean; - yField?: "y" | "y0"; - }): [number, number] { - // When 'current' is the first or last point of the array 'previous' or 'next' don't exist. Replace with 'current'. - const p = pp || cp; - const n = np || cp; - - // Properties of the opposed-line - let { angle, length } = this.getLineInfo(p.x, p[yField], n.x, n[yField]); - // If it is end-control-point, add PI to the angle to go backward - angle = angle + (reverse ? Math.PI : 0); - length = length * r; - // The control point position is relative to the current point - const x = cp.x + Math.cos(angle) * length; - const y = cp[yField] + Math.sin(angle) * length; - return [x, y]; - } - - getLineInfo(p1x: number, p1y: number, p2x: number, p2y: number): { length: number; angle: number } { - const lengthX = p2x - p1x; - const lengthY = p2y - p1y; - - return { - length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)), - angle: Math.atan2(lengthY, lengthX), - }; - } } LineGraph.prototype.xAxis = "x"; @@ -522,7 +432,6 @@ LineGraph.prototype.stack = "stack"; LineGraph.prototype.hiddenBase = false; LineGraph.prototype.smooth = false; -LineGraph.prototype.smoothingRatio = 0.05; LineGraph.prototype.styled = true; Widget.alias("line-graph", LineGraph);