// smoothPath — turn a polyline of points into a flowing SMOOTH curve. // // The straight-segment builders (`M x,y L x,y L x,y …`) draw the line as a // jagged polyline: every data point is a sharp angle. This converts the same // points into a MONOTONE-CUBIC spline (Fritsch–Carlson, == d3's curveMonotoneX) // emitted as cubic-bezier segments (`M x,y C c1 c2 p …`), so the line flows in // gentle curves through every point instead of kinking at each one. // // Why monotone-cubic and NOT Catmull-Rom (the previous curve): // Catmull-Rom makes pretty curves but "often produces inappropriate OVERSHOOTS // even for monotone data points" — it can shoot above a local max or below a // local min, i.e. it WOBBLES between points and invents values the data never // had. On a streaming chart those overshoots MOVE as the trailing control // points change, adding shimmer on top of the scroll. Monotone-cubic // (Fritsch–Carlson) guarantees "no overshoots beyond the given data points" — // the interpolant stays within the data's own bounds, so the curve can never // wobble or overshoot. It is the standard choice for data charts precisely // because it doesn't fabricate between-point values. See // en.wikipedia.org/wiki/Monotone_cubic_interpolation and d3-shape // `curveMonotoneX`. // // The algorithm (2-pass Fritsch–Carlson): // pass 1 — secant slopes between consecutive points, then a 3-point tangent at // each interior point (the Catmull-Rom-ish average); // pass 2 — clamp each tangent so it cannot exceed 3× the adjacent secant, // which is exactly what enforces monotonicity (kills overshoot). // The bezier control handles are then placed one third of the x-gap along each // point's tangent — the standard Hermite→bezier conversion. // // Pure geometry: this is NOT animation. The streaming engine scrolls the whole // ``; this builds the curve once per data change from the true points. // `tension` still exists for API compat (and `0` is the SHARP disable path), but // it no longer scales overshoot — monotone tangents need no tension knob. /** A point in viewBox space. */ export interface Pt { x: number; y: number; } /** * Default smoothing tension. Retained for API compatibility — any value > 0 * selects the monotone-cubic curve (which has no tension parameter of its own); * `0` degenerates to straight `L` segments for charts that need sharp corners * (ECG QRS spikes, which keep their own bespoke builder rather than this one). */ export const DEFAULT_TENSION = 0.5; /** * Build a smooth `d` path through `points` using monotone-cubic → cubic bezier. * * @param points anchor points, in draw order. The curve passes through each. * @param tension `0` → straight (`L`) segments; any value > 0 → monotone curve. * @param _bounds viewBox extent (kept for signature compat; monotone-cubic does * not overshoot, so no clamp is needed). * @returns the full `M…`-prefixed path. For the BODY only (no leading `M`), use * {@link smoothSegments}. */ export function smoothLinePath( points: readonly Pt[], tension: number, _bounds?: { width: number; height: number }, ): string { if (points.length === 0) return ''; const head = `${points[0].x.toFixed(2)},${points[0].y.toFixed(2)}`; if (points.length === 1) return `M${head}`; return `M${head} ${smoothSegments(points, tension)}`; } /** * The curve BODY (no leading `M`) — the `C …`/`L …` segments that connect the * points. Split out so the area builder can reuse the exact same curve between * its own `M0,baseline` open and `L…baseline Z` close (the fill must hug the * same flowing line as the stroke, not a straight re-trace). * * @param tension `0` → straight `L` segments (disable path); > 0 → monotone. * @param _bounds ignored (monotone-cubic does not overshoot) — kept for compat. */ export function smoothSegments( points: readonly Pt[], tension: number, _bounds?: { width: number; height: number }, ): string { if (points.length < 2) return ''; // tension <= 0 → straight polyline (the disable path; SHARP geometry). if (tension <= 0) { return points .slice(1) .map((p) => `L${p.x.toFixed(2)},${p.y.toFixed(2)}`) .join(' '); } const m = monotoneTangents(points); const out: string[] = []; for (let i = 0; i < points.length - 1; i += 1) { const p1 = points[i]; const p2 = points[i + 1]; const dx = (p2.x - p1.x) / 3; // Hermite → cubic bezier: handles sit one third of the x-gap along each // endpoint's tangent. Monotone tangents guarantee no overshoot, so no clamp. const c1x = p1.x + dx; const c1y = p1.y + dx * m[i]; const c2x = p2.x - dx; const c2y = p2.y - dx * m[i + 1]; out.push( `C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`, ); } return out.join(' '); } /** * Trace a monotone-cubic curve through `points` onto a Canvas2D context using the * SAME Fritsch–Carlson tangents as {@link smoothSegments} — the canvas analogue * of emitting the `C …`/`L …` body, via `bezierCurveTo`/`lineTo`. The caller owns * `beginPath()` and the opening `moveTo` (so the area filler can open at the * baseline and close after); this only appends the curve through the points. * * @param tension `0` → straight `lineTo` segments (the SHARP disable path); any * value > 0 → the monotone curve. */ export function traceSmoothSegments( ctx: CanvasRenderingContext2D, points: readonly Pt[], tension: number, ): void { if (points.length < 2) return; // tension <= 0 → straight polyline (the disable path; SHARP geometry). if (tension <= 0) { for (let i = 1; i < points.length; i += 1) { ctx.lineTo(points[i].x, points[i].y); } return; } const m = monotoneTangents(points); for (let i = 0; i < points.length - 1; i += 1) { const p1 = points[i]; const p2 = points[i + 1]; const dx = (p2.x - p1.x) / 3; // Hermite → cubic bezier: handles sit one third of the x-gap along each // endpoint's tangent. Monotone tangents guarantee no overshoot, so no clamp. ctx.bezierCurveTo( p1.x + dx, p1.y + dx * m[i], p2.x - dx, p2.y - dx * m[i + 1], p2.x, p2.y, ); } } /** * Fritsch–Carlson monotone tangents (the slope dy/dx at each point). Two passes: * a 3-point initial tangent, then a clamp that enforces monotonicity so the * resulting cubic cannot overshoot the data between points. Mirrors d3-shape's * `curveMonotoneX`. */ function monotoneTangents(points: readonly Pt[]): number[] { const n = points.length; const tangents = new Array(n).fill(0); if (n < 2) return tangents; // Secant slopes between consecutive points. const secants = new Array(n - 1); for (let i = 0; i < n - 1; i += 1) { const dx = points[i + 1].x - points[i].x; secants[i] = dx === 0 ? 0 : (points[i + 1].y - points[i].y) / dx; } // Pass 1 — endpoint tangents = adjacent secant; interior = average of the two // neighbouring secants (the 3-point tangent). tangents[0] = secants[0]; tangents[n - 1] = secants[n - 2]; for (let i = 1; i < n - 1; i += 1) { tangents[i] = (secants[i - 1] + secants[i]) / 2; } // Pass 2 — clamp tangents to enforce monotonicity (Fritsch–Carlson). Where a // secant is flat, pin both surrounding tangents to 0 (a local extreme stays // flat). Otherwise scale the tangent so (α, β) lie in the monotone circle of // radius 3, which is what kills overshoot. for (let i = 0; i < n - 1; i += 1) { const s = secants[i]; if (s === 0) { tangents[i] = 0; tangents[i + 1] = 0; continue; } const a = tangents[i] / s; const b = tangents[i + 1] / s; // If a tangent points the "wrong" way relative to the secant, flatten it. if (a < 0) tangents[i] = 0; if (b < 0) tangents[i + 1] = 0; const h = a * a + b * b; if (h > 9) { const t = 3 / Math.sqrt(h); tangents[i] = t * a * s; tangents[i + 1] = t * b * s; } } return tangents; }