packages/core/src/features/viz/lib/curve-definition.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.