react-native-livechart 4.8.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import type { ResolvedThresholdLineConfig } from "../core/resolveConfig";
12
12
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
13
13
  import type { ChartPadding } from "../draw/line";
14
14
  import { usePathBuilder } from "../hooks/usePathBuilder";
15
+ import { thresholdDashPhase } from "../math/threshold";
15
16
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
16
17
  import type { LiveChartPalette } from "../types";
17
18
 
@@ -32,6 +33,9 @@ interface ThresholdMarkerProps {
32
33
  palette: LiveChartPalette;
33
34
  font: SkFont;
34
35
  formatValue: (v: number) => string;
36
+ /** When set, draw the marker as this time-varying threshold polyline (`[x, y, …]`)
37
+ * instead of a horizontal line at `lineY`. */
38
+ seriesPts?: SharedValue<number[]>;
35
39
  }
36
40
 
37
41
  /**
@@ -47,18 +51,44 @@ export function ThresholdLineOverlay({
47
51
  visible,
48
52
  cfg,
49
53
  palette,
54
+ seriesPts,
50
55
  }: ThresholdMarkerProps) {
51
56
  const lineColor = cfg.color ?? palette.refLine;
52
57
  const { strokeWidth, intervals } = cfg;
58
+ const isSeries = seriesPts !== undefined;
59
+ const dashCycle = intervals[0] + intervals[1];
60
+
61
+ // Series marker: advance the dash phase with the content scroll so the
62
+ // pattern rides the staircase instead of sitting screen-fixed under it (the
63
+ // path start is pinned at the plot edge, so a static phase reads as the dots
64
+ // marching right along the gliding line). The constant marker is a static
65
+ // level — its dashes stay screen-fixed (phase 0).
66
+ const dashPhase = useDerivedValue(() => {
67
+ if (!isSeries) return 0;
68
+ return thresholdDashPhase(
69
+ engine.timestamp.get(),
70
+ engine.displayWindow.get(),
71
+ padding.left,
72
+ engine.canvasWidth.get() - padding.right,
73
+ dashCycle,
74
+ );
75
+ });
53
76
 
54
77
  const builder = usePathBuilder();
55
78
 
56
79
  const linePath = useDerivedValue(() => {
57
80
  const b = builder.value;
58
81
  if (visible.get()) {
59
- const y = lineY.get();
60
- b.moveTo(padding.left, y);
61
- b.lineTo(engine.canvasWidth.get() - padding.right, y);
82
+ const sp = seriesPts?.get();
83
+ if (sp && sp.length >= 4) {
84
+ // Time-varying threshold: trace the polyline across the plot.
85
+ b.moveTo(sp[0], sp[1]);
86
+ for (let i = 2; i < sp.length; i += 2) b.lineTo(sp[i], sp[i + 1]);
87
+ } else {
88
+ const y = lineY.get();
89
+ b.moveTo(padding.left, y);
90
+ b.lineTo(engine.canvasWidth.get() - padding.right, y);
91
+ }
62
92
  }
63
93
  return b.detach();
64
94
  });
@@ -73,7 +103,7 @@ export function ThresholdLineOverlay({
73
103
  strokeWidth={strokeWidth}
74
104
  color={lineColor}
75
105
  >
76
- <DashPathEffect intervals={intervals} />
106
+ <DashPathEffect intervals={intervals} phase={dashPhase} />
77
107
  </Path>
78
108
  </Group>
79
109
  );
@@ -100,7 +130,7 @@ export function ThresholdBadgeOverlay({
100
130
  const { label, labelPosition, showValue } = cfg;
101
131
  const badgeBackground = palette.tooltipBg;
102
132
  const badgeBorderColor = cfg.color ?? palette.refLine;
103
- const labelColor = cfg.color ?? palette.refLabel;
133
+ const labelColor = cfg.labelColor ?? cfg.color ?? palette.refLabel;
104
134
 
105
135
  // Font metrics are stable → read once (getMetrics allocates + crosses JSI).
106
136
  const { fontAscent, baselineOffset, pillH } = (() => {
@@ -0,0 +1,92 @@
1
+ import { Shader, Skia, type Uniforms } from "@shopify/react-native-skia";
2
+ import type { SharedValue } from "react-native-reanimated";
3
+
4
+ import { THRESHOLD_SAMPLE_COUNT } from "../math/threshold";
5
+
6
+ /**
7
+ * Per-fragment split shader for a *time-varying* threshold. For each fragment it
8
+ * reads the threshold's Y at the fragment's X — linearly interpolated from
9
+ * `samples[]`, evenly spaced across `[plotLeft, plotRight]` — and paints
10
+ * `aboveColor` above the boundary (smaller Y), `belowColor` below. This is the
11
+ * polyline equivalent of the constant case's vertical hard-stop `LinearGradient`,
12
+ * which can only express a *horizontal* split.
13
+ *
14
+ * Used for both the line stroke and the profit/loss fill band (the band passes
15
+ * alpha-reduced colors). Output is premultiplied — Skia's shader convention, as in
16
+ * `AreaDotsOverlay`. One GPU draw, no per-frame clip / `saveLayer`.
17
+ */
18
+ const SPLIT_SKSL = `
19
+ // Pixel-X span of the sample grid (thresholdSampleSpanX) — overhangs the plot
20
+ // and glides with the window so features move fluidly, frame to frame.
21
+ uniform float sampleLeft;
22
+ uniform float sampleRight;
23
+ // X where the threshold ends (extendToNow: false → the last point's pixel-X,
24
+ // else a huge sentinel). Fragments right of it paint restColor: the plain line
25
+ // color for the stroke, transparent for the band.
26
+ uniform float clipRight;
27
+ uniform vec4 aboveColor; // straight-alpha rgba, 0..1
28
+ uniform vec4 belowColor; // straight-alpha rgba, 0..1
29
+ uniform vec4 restColor; // straight-alpha rgba, 0..1
30
+ uniform float samples[${THRESHOLD_SAMPLE_COUNT}];
31
+
32
+ half4 main(vec2 xy) {
33
+ if (xy.x > clipRight) {
34
+ return half4(half3(restColor.rgb) * restColor.a, restColor.a);
35
+ }
36
+ float span = sampleRight - sampleLeft;
37
+ float u = span > 0.0 ? (xy.x - sampleLeft) / span : 0.0;
38
+ u = clamp(u, 0.0, 1.0) * float(${THRESHOLD_SAMPLE_COUNT - 1});
39
+ // SkSL forbids indexing a uniform array with a non-constant index, so walk the
40
+ // segments in an unrolled, constant-bound loop — the index is the loop variable
41
+ // (a compile-time constant per unrolled iteration) — and keep the last segment
42
+ // whose left edge is at or before the fragment's u.
43
+ float thrY = samples[0];
44
+ for (int j = 0; j < ${THRESHOLD_SAMPLE_COUNT - 1}; j++) {
45
+ if (u >= float(j)) {
46
+ thrY = mix(samples[j], samples[j + 1], clamp(u - float(j), 0.0, 1.0));
47
+ }
48
+ }
49
+ // 1 above the boundary, 0 below, with ~1px antialiasing across it.
50
+ float above = clamp(thrY - xy.y + 0.5, 0.0, 1.0);
51
+ vec4 c = mix(belowColor, aboveColor, above);
52
+ return half4(half3(c.rgb) * c.a, c.a);
53
+ }`;
54
+
55
+ // Compiled once. `RuntimeEffect.Make` THROWS on a compile error, so guard it —
56
+ // a shader failure must degrade to "no split" (the line keeps its plain color),
57
+ // never crash every chart screen at import. Null → the component no-ops.
58
+ let SPLIT_EFFECT: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
59
+ try {
60
+ SPLIT_EFFECT = Skia.RuntimeEffect.Make(SPLIT_SKSL);
61
+ } catch {
62
+ SPLIT_EFFECT = null;
63
+ }
64
+
65
+ /**
66
+ * Whether the split effect compiled. Callers must gate any `<Path>` whose ONLY
67
+ * paint is this shader on it — without the shader child the path fills with the
68
+ * default paint (opaque black). A fallback `color` prop can't stand in instead:
69
+ * Skia multiplies the shader output by the paint alpha, so a transparent color
70
+ * erases the shader's own output too.
71
+ */
72
+ export const THRESHOLD_SPLIT_AVAILABLE = SPLIT_EFFECT !== null;
73
+
74
+ interface ThresholdSplitShaderProps {
75
+ /**
76
+ * Per-frame uniforms (built in `useThresholdSplitUniforms`): `sampleLeft` /
77
+ * `sampleRight` (the gliding sample-grid span, px), `aboveColor` / `belowColor`
78
+ * (straight-alpha `[r, g, b, a]` vec4s, channels 0..1) and `samples`
79
+ * (`THRESHOLD_SAMPLE_COUNT` threshold pixel-Y values on the grid).
80
+ */
81
+ uniforms: SharedValue<Uniforms>;
82
+ }
83
+
84
+ /**
85
+ * The split `RuntimeShader` as a paint child — drop it inside a stroked line
86
+ * `<Path>` or a filled band `<Path>`, like a `<LinearGradient>`.
87
+ */
88
+ export function ThresholdSplitShader({ uniforms }: ThresholdSplitShaderProps) {
89
+ /* istanbul ignore next -- defensive: SPLIT_SKSL is static and compiles */
90
+ if (!SPLIT_EFFECT) return null;
91
+ return <Shader source={SPLIT_EFFECT} uniforms={uniforms} />;
92
+ }
@@ -2,6 +2,10 @@ import type { CandlePoint, LiveChartPoint } from "../types";
2
2
 
3
3
  import { MOTION_METRICS_DEFAULTS } from "../constants";
4
4
  import { lerp } from "../math/lerp";
5
+ import { thresholdRangeMinMax } from "../math/threshold";
6
+
7
+ /** Scratch for the threshold range fold — only alive within one tick call. */
8
+ const THRESHOLD_RANGE_SCRATCH: [number, number] = [0, 0];
5
9
 
6
10
  export interface EngineTickMutable {
7
11
  displayValue: number;
@@ -48,6 +52,15 @@ export interface EngineTickInput {
48
52
  referenceValue: number | undefined;
49
53
  /** Additional reference values (lines + bands) folded into the Y range. */
50
54
  referenceValues?: number[];
55
+ /**
56
+ * Time-varying threshold series (`threshold.includeInRange`): its min/max over
57
+ * the visible window is folded into the Y range, like {@link referenceValues}
58
+ * but windowed each tick.
59
+ */
60
+ thresholdRangePoints?: LiveChartPoint[];
61
+ /** Whether {@link thresholdRangePoints} extends flat past its last point to
62
+ * "now" (`threshold.extendToNow`). Default `true`. */
63
+ thresholdRangeExtendToNow?: boolean;
51
64
  /** Clamp the computed lower bound at 0. */
52
65
  nonNegative?: boolean;
53
66
  /** Hard cap for the computed upper bound. */
@@ -285,6 +298,21 @@ export function tickLiveChartEngineFrame(
285
298
  }
286
299
  }
287
300
 
301
+ const thrPts = input.thresholdRangePoints;
302
+ if (thrPts !== undefined && thrPts.length > 0) {
303
+ const mm = thresholdRangeMinMax(
304
+ thrPts,
305
+ state.timestamp,
306
+ state.displayWindow,
307
+ input.thresholdRangeExtendToNow ?? true,
308
+ THRESHOLD_RANGE_SCRATCH,
309
+ );
310
+ if (mm !== null) {
311
+ if (mm[0] < tMin) tMin = mm[0];
312
+ if (mm[1] > tMax) tMax = mm[1];
313
+ }
314
+ }
315
+
288
316
  const isExaggerate = input.exaggerate;
289
317
  if (tMin !== Infinity && tMax !== -Infinity) {
290
318
  const rawRange = tMax - tMin;
@@ -14,6 +14,7 @@ import type {
14
14
  LineStyleConfig,
15
15
  LiveChartMetrics,
16
16
  LiveChartMetricsOverride,
17
+ LiveChartPoint,
17
18
  LoadingConfig,
18
19
  MarkerClusterConfig,
19
20
  DotConfig,
@@ -326,16 +327,31 @@ export interface ResolvedThresholdLineConfig {
326
327
  intervals: [number, number];
327
328
  strokeWidth: number;
328
329
  showValue: boolean;
330
+ /** undefined → fall back to `color`, then palette.refLabel at render time. */
331
+ labelColor: string | undefined;
329
332
  }
330
333
 
331
334
  export interface ResolvedThresholdConfig {
332
- /** The live split value (Y-axis units). Read on the UI thread each frame. */
333
- value: SharedValue<number>;
335
+ /**
336
+ * The split value (Y-axis units). A `SharedValue<number>` for a single live
337
+ * benchmark (read on the UI thread each frame), or a `LiveChartPoint[]` for a
338
+ * time-varying threshold the split follows point-for-point. `undefined` when
339
+ * {@link series} carries the threshold instead.
340
+ */
341
+ value: SharedValue<number> | LiveChartPoint[] | undefined;
342
+ /** Live time-varying threshold (`SharedValue<LiveChartPoint[]>`); wins over `value`. */
343
+ series: SharedValue<LiveChartPoint[]> | null;
334
344
  /** undefined → use palette.candleUp (up-green) at render time. */
335
345
  aboveColor: string | undefined;
336
346
  /** undefined → use palette.candleDown (down-red) at render time. */
337
347
  belowColor: string | undefined;
338
348
  fill: boolean;
349
+ /** Band opacity (0–1); {@link THRESHOLD_FILL_OPACITY_DEFAULT} unless tuned. */
350
+ fillOpacity: number;
351
+ /** Fold the threshold into the Y-axis range fit. */
352
+ includeInRange: boolean;
353
+ /** Series forms: extend flat past the last point to "now". */
354
+ extendToNow: boolean;
339
355
  line: ResolvedThresholdLineConfig | null;
340
356
  }
341
357
 
@@ -346,8 +362,12 @@ const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
346
362
  intervals: [4, 4],
347
363
  strokeWidth: 1,
348
364
  showValue: false,
365
+ labelColor: undefined,
349
366
  };
350
367
 
368
+ /** Default threshold band opacity — matches reference-line bands. */
369
+ export const THRESHOLD_FILL_OPACITY_DEFAULT = 0.16;
370
+
351
371
  /**
352
372
  * Resolves the `threshold.line` sub-prop to a config or null (no marker line).
353
373
  * `true` → dashed defaults, object → merged, falsy/undefined → null.
@@ -360,18 +380,24 @@ export function resolveThresholdLine(
360
380
 
361
381
  /**
362
382
  * Resolves the `threshold` prop to a fully-typed config or null (disabled).
363
- * Presence-gated (like `referenceLines`/`markers`): the required `value`
364
- * SharedValue means there is no boolean form — a config object enables it.
383
+ * Presence-gated (like `referenceLines`/`markers`): a config object with a
384
+ * `value` or `series` enables it — there is no boolean form.
365
385
  */
366
386
  export function resolveThreshold(
367
387
  prop: ThresholdConfig | undefined,
368
388
  ): ResolvedThresholdConfig | null {
369
- if (!prop) return null;
389
+ if (!prop || (prop.value == null && prop.series == null)) return null;
370
390
  return {
371
391
  value: prop.value,
392
+ series: prop.series ?? null,
372
393
  aboveColor: prop.aboveColor,
373
394
  belowColor: prop.belowColor,
374
- fill: prop.fill ?? false,
395
+ fill: !!prop.fill,
396
+ fillOpacity:
397
+ (typeof prop.fill === "object" ? prop.fill.opacity : undefined) ??
398
+ THRESHOLD_FILL_OPACITY_DEFAULT,
399
+ includeInRange: prop.includeInRange ?? false,
400
+ extendToNow: prop.extendToNow ?? true,
375
401
  line: resolveThresholdLine(prop.line),
376
402
  };
377
403
  }
@@ -37,6 +37,17 @@ export interface EngineConfig {
37
37
  * `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts.
38
38
  */
39
39
  liveReferenceValues?: SharedValue<number[]>;
40
+ /**
41
+ * Time-varying threshold series folded into the axis-range fit
42
+ * (`threshold.includeInRange`): each tick contributes the series' min/max over
43
+ * the visible window — like {@link referenceValues}, but windowed. A
44
+ * `SharedValue` for the live `threshold.series` form, a plain array for the
45
+ * `LiveChartPoint[]` form.
46
+ */
47
+ thresholdRangePoints?: SharedValue<LiveChartPoint[]> | LiveChartPoint[];
48
+ /** With {@link thresholdRangePoints}: whether the series extends flat past its
49
+ * last point to "now" (`threshold.extendToNow`). Default `true`. */
50
+ thresholdRangeExtendToNow?: boolean;
40
51
  nonNegative?: boolean;
41
52
  maxValue?: number;
42
53
  nowOverride?: number;
@@ -183,6 +194,9 @@ export interface EngineFrameRefs {
183
194
  exaggerateSV: SharedValue<boolean>;
184
195
  referenceValue: SharedValue<number | undefined>;
185
196
  referenceValues?: SharedValue<number[] | undefined>;
197
+ /** Series threshold folded into the range fit (`threshold.includeInRange`). */
198
+ thresholdRangePoints?: SharedValue<LiveChartPoint[] | undefined>;
199
+ thresholdRangeExtendToNow?: SharedValue<boolean>;
186
200
  nonNegativeSV?: SharedValue<boolean>;
187
201
  maxValueSV?: SharedValue<number | undefined>;
188
202
  nowOverrideSV?: SharedValue<number | undefined>;
@@ -251,6 +265,8 @@ export function applyLiveChartEngineFrame(
251
265
  exaggerate: sv.exaggerateSV.value,
252
266
  referenceValue: sv.referenceValue.value,
253
267
  referenceValues: sv.referenceValues?.value,
268
+ thresholdRangePoints: sv.thresholdRangePoints?.value,
269
+ thresholdRangeExtendToNow: sv.thresholdRangeExtendToNow?.value ?? true,
254
270
  nonNegative: sv.nonNegativeSV?.value ?? false,
255
271
  maxValue: sv.maxValueSV?.value,
256
272
  nowOverride: sv.nowOverrideSV?.value,
@@ -325,6 +341,23 @@ export function useLiveChartEngine(
325
341
  if (!live || live.length === 0) return base;
326
342
  return base && base.length > 0 ? base.concat(live) : live;
327
343
  });
344
+ // Threshold range fold (`threshold.includeInRange`, series forms). Captured
345
+ // directly (not via `config.`) like `liveReferenceValues`, so the live
346
+ // `threshold.series` SharedValue is tracked and the tick sees fresh points.
347
+ const thresholdRangeSourceSV =
348
+ config.thresholdRangePoints !== undefined &&
349
+ !Array.isArray(config.thresholdRangePoints)
350
+ ? config.thresholdRangePoints
351
+ : null;
352
+ const thresholdRangePoints = useDerivedValue<LiveChartPoint[] | undefined>(
353
+ () =>
354
+ thresholdRangeSourceSV
355
+ ? thresholdRangeSourceSV.value
356
+ : (config.thresholdRangePoints as LiveChartPoint[] | undefined),
357
+ );
358
+ const thresholdRangeExtendToNow = useDerivedValue(
359
+ () => config.thresholdRangeExtendToNow ?? true,
360
+ );
328
361
  const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
329
362
  const maxValueSV = useDerivedValue(() => config.maxValue);
330
363
  const nowOverrideSV = useDerivedValue(() => config.nowOverride);
@@ -412,6 +445,8 @@ export function useLiveChartEngine(
412
445
  exaggerateSV,
413
446
  referenceValue,
414
447
  referenceValues,
448
+ thresholdRangePoints,
449
+ thresholdRangeExtendToNow,
415
450
  nonNegativeSV,
416
451
  maxValueSV,
417
452
  nowOverrideSV,
@@ -4,6 +4,7 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
4
  import type { SingleEngineState } from "../core/useLiveChartEngine";
5
5
  import { buildLinePoints, type ChartPadding } from "../draw/line";
6
6
  import { drawSpline, makeSplineScratch } from "../math/spline";
7
+ import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
7
8
  import { blendPtsY, squigglifyPts } from "../math/squiggly";
8
9
  import { usePathBuilder } from "./usePathBuilder";
9
10
 
@@ -39,6 +40,11 @@ export function useChartPaths(
39
40
  squiggleAmplitude = 14,
40
41
  /** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
41
42
  squiggleSpeed = 1,
43
+ /** When set, build `thresholdFillPath` as the band between the line and this
44
+ * *time-varying* threshold — the split shader's evenly-spaced pixel-Y
45
+ * `samples[]` (so band geometry matches the shader exactly). Takes precedence
46
+ * over `thresholdY`, the constant (horizontal) case. */
47
+ thresholdSamples?: SharedValue<number[]>,
42
48
  ) {
43
49
  const lineBuilder = usePathBuilder();
44
50
  const fillBuilder = usePathBuilder();
@@ -134,17 +140,55 @@ export function useChartPaths(
134
140
  return b.detach();
135
141
  });
136
142
 
137
- // Threshold-anchored fill: the same spline, closed at `thresholdY` instead of the
138
- // baseline, so the band lies between the line and the threshold (the profit/loss
139
- // area). Painted with the hard-split vertical gradient, the part above the split
140
- // shows the above-color and the part below shows the below-color.
143
+ // Threshold-anchored fill: the same spline, closed along the threshold instead
144
+ // of the baseline, so the band lies between the line and the threshold (the
145
+ // profit/loss area). Painted with the split gradient/shader, the part above the
146
+ // split shows the above-color and the part below shows the below-color.
147
+ //
148
+ // `thresholdPts` (a time-varying series) closes the band along that polyline,
149
+ // right-to-left; otherwise `thresholdY` closes it at a single horizontal Y.
141
150
  const thresholdFillPath = useDerivedValue(() => {
142
151
  const cache = cacheRef.current!;
143
- if (!thresholdY) return cache.emptyPath;
144
- const yT = thresholdY.get();
145
152
  const pts = flatPts.get();
146
153
  const n = pts.length >> 1;
147
- if (n < 2 || !Number.isFinite(yT)) return cache.emptyPath;
154
+ if (n < 2) return cache.emptyPath;
155
+
156
+ const tsamples = thresholdSamples?.get();
157
+ if (tsamples && tsamples.length >= 2) {
158
+ const b = thresholdFillBuilder.value;
159
+ b.moveTo(pts[0], pts[1]);
160
+ drawSpline(b, pts, cache.scratch, linear);
161
+ // Band bottom = the SAMPLED threshold (identical to what the split shader
162
+ // reads), pinned to the LINE's x-range. Because the geometry and the shader
163
+ // use the same evenly-spaced, linearly-interpolated samples, a step riser
164
+ // ramps the same way in both — no green/red sliver bleeds through — and the
165
+ // x-range pin keeps the band closing with clean vertical sides (no wedge).
166
+ const leftX = pts[0];
167
+ const rightX = pts[(n - 1) * 2];
168
+ const count = tsamples.length;
169
+ // The samples live on the time-anchored, gliding grid — interpolate them
170
+ // across that span (same as the shader), not the static plot edges.
171
+ const [x0, x1] = thresholdSampleSpanX(
172
+ engine.timestamp.get(),
173
+ engine.displayWindow.get(),
174
+ padding.left,
175
+ engine.canvasWidth.get() - padding.right,
176
+ count,
177
+ );
178
+ const step = (x1 - x0) / (count - 1);
179
+ b.lineTo(rightX, sampleThresholdYAt(tsamples, x0, x1, rightX));
180
+ for (let i = count - 1; i >= 0; i--) {
181
+ const sx = x0 + step * i;
182
+ if (sx > leftX && sx < rightX) b.lineTo(sx, tsamples[i]);
183
+ }
184
+ b.lineTo(leftX, sampleThresholdYAt(tsamples, x0, x1, leftX));
185
+ b.close();
186
+ return b.detach();
187
+ }
188
+
189
+ if (!thresholdY) return cache.emptyPath;
190
+ const yT = thresholdY.get();
191
+ if (!Number.isFinite(yT)) return cache.emptyPath;
148
192
  const b = thresholdFillBuilder.value;
149
193
  b.moveTo(pts[0], pts[1]);
150
194
  drawSpline(b, pts, cache.scratch, linear);