Skip to content

packages/core/src/features/viz/lib/curve-definition.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import type { LineSeriesProps } from "./types.js";
2 /** A mapped Cartesian coordinate used by private series curve rendering. */
3 export type SeriesCurvePoint = readonly [number, number];
4 
5 /**
6  * A private generated segment. Cubic controls reserve the representation needed by a later
7  * smooth-curve implementation; current step definitions generate straight segments only.
8  */
9 export interface SeriesCurveSegment {
10   /** Endpoint of this segment. */
11   readonly end: SeriesCurvePoint;
12   /** First cubic Bézier control point when the segment is cubic. */
13   readonly control1?: SeriesCurvePoint;
14   /** Second cubic Bézier control point when the segment is cubic. */
15   readonly control2?: SeriesCurvePoint;
16 }
17 
18 /** A private immutable path beginning at one point and continuing through generated segments. */
19 export interface SeriesCurveGeometry {
20   /** Initial coordinate for the geometry. */
21   readonly start: SeriesCurvePoint;
22   /** Ordered segments after {@link SeriesCurveGeometry.start}. */
23   readonly segments: readonly SeriesCurveSegment[];
24 }
25 
26 declare const seriesCurveBrand: unique symbol;
27 
28 /**
29  * An opaque immutable definition controlling how a series connects adjacent observations.
30  *
31  * Obtain a definition from one of the named curve factories. Definitions intentionally expose
32  * no algorithm selector or custom callback surface.
33  *
34  * @see {@link LineSeriesProps}
35  */
36 export interface SeriesCurve {
37   /** Nominal marker that prevents callers from forging a recognized definition. */
38   readonly [seriesCurveBrand]: true;
39 }
40 
41 type SeriesCurveBuilder = (
42   points: readonly SeriesCurvePoint[],
43 ) => readonly SeriesCurveSegment[];
44 
45 const builders = new WeakMap<SeriesCurve, SeriesCurveBuilder>();
46 
47 /** @internal Creates one module-recognized immutable curve definition. */
48 export function createSeriesCurve(builder: SeriesCurveBuilder): SeriesCurve {
49   const definition = Object.freeze({}) as unknown as SeriesCurve;
50   builders.set(definition, builder);
51   return definition;
52 }
53 
54 /** @internal Rejects values that were not created by a named curve factory. */
55 export function resolveSeriesCurve(value: unknown): SeriesCurve | undefined {
56   if (value === undefined || value === "linear") return undefined;
57   if (
58     typeof value !== "object" ||
59     value === null ||
60     !builders.has(value as SeriesCurve)
61   )
62     throw new TypeError(
63       'Series curve must be "linear" or a definition from a named factory.',
64     );
65   return value as SeriesCurve;
66 }
67 
68 /** @internal Builds the curve geometry for one already-valid contiguous run. */
69 export function curveGeometry(
70   curve: SeriesCurve | undefined,
71   points: readonly SeriesCurvePoint[],
72 ): SeriesCurveGeometry {
73   const start = points[0];
74   return Object.freeze({
75     start,
76     segments: Object.freeze(
77       curve
78         ? [...builders.get(curve)!(points)]
79         : points.slice(1).map((end) => Object.freeze({ end })),
80     ),
81   });
82 }
83 
84 /** @internal Appends one generated geometry in its authored forward direction. */
85 export function appendCurveForward(
86   path: Path2D,
87   geometry: SeriesCurveGeometry,
88 ): void {
89   for (const segment of geometry.segments) {
90     if (segment.control1 && segment.control2)
91       path.bezierCurveTo(
92         segment.control1[0],
93         segment.control1[1],
94         segment.control2[0],
95         segment.control2[1],
96         segment.end[0],
97         segment.end[1],
98       );
99     else path.lineTo(segment.end[0], segment.end[1]);
100   }
101 }
102 
103 /** @internal Appends one generated geometry in reverse without rebuilding its curve strategy. */
104 export function appendCurveReverse(
105   path: Path2D,
106   geometry: SeriesCurveGeometry,
107 ): void {
108   const ends = geometry.segments.map((segment) => segment.end);
109   path.lineTo(
110     (ends.at(-1) ?? geometry.start)[0],
111     (ends.at(-1) ?? geometry.start)[1],
112   );
113   for (let i = geometry.segments.length - 1; i >= 0; i--) {
114     const segment = geometry.segments[i];
115     const start = i === 0 ? geometry.start : geometry.segments[i - 1].end;
116     if (segment.control1 && segment.control2)
117       path.bezierCurveTo(
118         segment.control2[0],
119         segment.control2[1],
120         segment.control1[0],
121         segment.control1[1],
122         start[0],
123         start[1],
124       );
125     else path.lineTo(start[0], start[1]);
126   }
127 }
128 
129 function cubicValue(
130   values: readonly [number, number, number, number],
131   t: number,
132 ): number {
133   const [p0, p1, p2, p3] = values;
134   const inverse = 1 - t;
135   return (
136     inverse ** 3 * p0 +
137     3 * inverse ** 2 * t * p1 +
138     3 * inverse * t ** 2 * p2 +
139     t ** 3 * p3
140   );
141 }
142 
143 function derivativeRoots(
144   values: readonly [number, number, number, number],
145 ): readonly number[] {
146   const [p0, p1, p2, p3] = values;
147   const a = -p0 + 3 * p1 - 3 * p2 + p3;
148   const b = 3 * p0 - 6 * p1 + 3 * p2;
149   const c = -3 * p0 + 3 * p1;
150   if (a === 0) {
151     if (b === 0) return [];
152     return [-c / (2 * b)];
153   }
154   const discriminant = 4 * b * b - 12 * a * c;
155   if (discriminant < 0) return [];
156   const root = Math.sqrt(discriminant);
157   const q = -b - (b >= 0 ? root / 2 : -root / 2);
158   return q === 0 ? [-b / (3 * a)] : [q / (3 * a), c / q];
159 }
160 
161 function cubicY(
162   start: SeriesCurvePoint,
163   segment: SeriesCurveSegment,
164 ): readonly [number, number, number, number] {
165   const end = segment.end;
166   const control1 = segment.control1 ?? [
167     start[0] + (end[0] - start[0]) / 3,
168     start[1] + (end[1] - start[1]) / 3,
169   ];
170   const control2 = segment.control2 ?? [
171     start[0] + ((end[0] - start[0]) * 2) / 3,
172     start[1] + ((end[1] - start[1]) * 2) / 3,
173   ];
174   return [start[1], control1[1], control2[1], end[1]];
175 }
176 
177 /** @internal Rejects interval-band cubics whose paired boundaries cross between observations. */
178 export function validatePairedCurveGeometry(
179   upper: SeriesCurveGeometry,
180   lower: SeriesCurveGeometry,
181 ): void {
182   if (upper.segments.length !== lower.segments.length)
183     throw new RangeError(
184       "IntervalBandSeries curve boundaries have incompatible geometry.",
185     );
186   let upperStart = upper.start;
187   let lowerStart = lower.start;
188   let orientation = 0;
189   for (let i = 0; i < upper.segments.length && orientation === 0; i++) {
190     for (const value of [
191       upperStart[1] - lowerStart[1],
192       upper.segments[i].end[1] - lower.segments[i].end[1],
193     ]) {
194       if (value !== 0) {
195         orientation = Math.sign(value);
196         break;
197       }
198     }
199     upperStart = upper.segments[i].end;
200     lowerStart = lower.segments[i].end;
201   }
202   upperStart = upper.start;
203   lowerStart = lower.start;
204   for (let i = 0; i < upper.segments.length; i++) {
205     const upperSegment = upper.segments[i],
206       lowerSegment = lower.segments[i];
207     const upperY = cubicY(upperStart, upperSegment);
208     const lowerY = cubicY(lowerStart, lowerSegment);
209     const difference = upperY.map((value, index) => value - lowerY[index]) as [
210       number,
211       number,
212       number,
213       number,
214     ];
215     if (!difference.every(Number.isFinite))
216       throw new RangeError(
217         "IntervalBandSeries curve boundaries must be finite.",
218       );
219     const magnitude = Math.max(...difference.map(Math.abs));
220     const normalized = difference.map((value) =>
221       magnitude === 0 ? 0 : value / magnitude,
222     ) as [number, number, number, number];
223     const candidates = [0, 1, ...derivativeRoots(normalized)].filter(
224       (value) => value >= 0 && value <= 1,
225     );
226     for (const t of candidates) {
227       const value = cubicValue(normalized, t);
228       if (
229         !Number.isFinite(value) ||
230         // Relative tolerance accounts only for floating-point evaluation at tangencies.
231         (orientation !== 0 && value * orientation < -64 * Number.EPSILON)
232       )
233         throw new RangeError("IntervalBandSeries curve boundaries cross.");
234     }
235     upperStart = upperSegment.end;
236     lowerStart = lowerSegment.end;
237   }
238 }
239 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.