Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions litmus/features/charts/line-graph/SmoothingOvershoot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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 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 = {
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.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);

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,
);
}
}

const SmoothingChart = ({ smoothBinding }) => (
<cx>
<div>
<div style="display: flex; align-items: center; gap: 10px; padding-left: 50px">
<Select value-bind={smoothBinding}>
<option value="off">No smoothing</option>
<option value="bezier">Bezier (smooth=true)</option>
<option value="monotone">Monotone (smooth="monotone")</option>
</Select>
</div>
<Svg style="width:560px; height:500px;">
<Chart
offset="100 -10 -100 50"
axes={{
x: { type: NumericAxis, lineStyle: "stroke: transparent" },
y: { type: NumericAxis, vertical: true },
}}
>
<Gridlines />

<MarkerLine
y-bind="$page.yMax"
visible-bind="$page.showBounds"
style="stroke: red; stroke-dasharray: 4 4"
/>
<MarkerLine
y-bind="$page.yMin"
visible-bind="$page.showBounds"
style="stroke: red; stroke-dasharray: 4 4"
/>

<LineGraph
data-bind="$page.points"
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"
legend={false}
/>
<LineGraph
data-bind="$page.points"
visible-bind="$page.showRawLine"
lineStyle="stroke: #333; stroke-dasharray: 3 3; stroke-width: 1.5"
legend={false}
/>

<Repeater records-bind="$page.points">
<Marker
visible-bind="$page.showMarkers"
x-bind="$record.x"
y-bind="$record.y"
size={5}
shape="circle"
style="fill: #333; stroke: #333"
legend={false}
/>
</Repeater>
</Chart>
</Svg>
</div>
</cx>
);

export default (
<cx>
<div class="widgets" style="padding-left: 30px" controller={PageController}>
<div style="display: flex; gap: 20px">
<SmoothingChart smoothBinding="$page.smooth1" />
<SmoothingChart smoothBinding="$page.smooth2" />
</div>

<div
style="display: flex; flex-direction: column; gap: 10px; margin-top: 20px; width: 500px"
layout={LabelsLeftLayout}
>
<Select label="Dataset" value-bind="$page.dataset">
<option value="step" text={datasets.step.label} />
<option value="spike" text={datasets.spike.label} />
<option value="zeros" text={datasets.zeros.label} />
<option value="plateau" text={datasets.plateau.label} />
<option value="unevenX" text={datasets.unevenX.label} />
</Select>

<Slider
label="Smoothing ratio (bezier)"
value={{ bind: "$page.smoothingRatio", debounce: 100 }}
maxValue={0.4}
minValue={0}
step={0.01}
help-tpl="{$page.smoothingRatio:n;0;2}"
/>
<Switch label="Area" value-bind="$page.showArea" />
<Switch label="Show actual data (dashed)" value-bind="$page.showRawLine" />
<Switch label="Show markers" value-bind="$page.showMarkers" />
<Switch label="Show data min/max bounds" value-bind="$page.showBounds" />
</div>

<div style="margin-top: 20px; max-width: 700px; color: #666">
<p>
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 (<code>smooth="monotone"</code>) stays
within the data bounds.
</p>
</div>
</div>
</cx>
);
3 changes: 2 additions & 1 deletion litmus/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
99 changes: 86 additions & 13 deletions packages/cx/src/charts/LineGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<boolean | "monotone">;

/** 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`. */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = (
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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[],
Expand Down
Loading