react-native-vroom-chart 0.13.0 → 0.14.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.
@@ -1,18 +1,31 @@
1
1
  // VroomChart — Phase 3.
2
2
  //
3
- // Owns a SharedValue<SkPicture> driven by:
4
- // - useChartCore's "initial" picture (when data/size/range change), AND
5
- // - Pan gesture callbacks that call handle.pan(dx, dy) → fresh picture.
3
+ // Owns SharedValues driven by:
4
+ // - useChartCore's "initial" frame (when data/size/range change), AND
5
+ // - Pan gesture callbacks that call handle.pan(dx, dy) → a fresh frame.
6
6
  //
7
- // Reanimated 4 + RN-Skia 2 propagate SharedValue<SkPicture> changes to
8
- // <Picture> without a React re-render, so gesture-driven redraws are cheap.
7
+ // iOS wraps an SkPicture in-process. Android rasterizes to an SkImage (the
8
+ // two Skia copies can't share a picture pointer) so pan/zoom don't serialize
9
+ // the scene — and the system typeface — on every frame.
10
+ //
11
+ // Reanimated 4 + RN-Skia 2 propagate SharedValue changes to <Picture>/<Image>
12
+ // without a React re-render, so gesture-driven redraws are cheap.
9
13
  //
10
14
  // Gestures run on the JS thread for now (`runOnJS(true)`) — installing the
11
15
  // JSI bindings on the worklet runtime is a later perf optimization.
12
16
 
13
17
  import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
14
- import { View, type LayoutChangeEvent } from 'react-native';
15
- import { Canvas, Picture, Skia, type SkPicture } from '@shopify/react-native-skia';
18
+ import { PixelRatio, View, type LayoutChangeEvent } from 'react-native';
19
+ import {
20
+ AlphaType,
21
+ Canvas,
22
+ ColorType,
23
+ Image,
24
+ Picture,
25
+ Skia,
26
+ type SkImage,
27
+ type SkPicture,
28
+ } from '@shopify/react-native-skia';
16
29
  import {
17
30
  Gesture,
18
31
  GestureDetector,
@@ -22,9 +35,14 @@ import { useReducedMotion, useSharedValue } from 'react-native-reanimated';
22
35
 
23
36
  import { useChartCore } from './useChartCore';
24
37
  import { ease, easingIndex } from './easing';
38
+ import type { ChartFrame } from './jsi.d';
25
39
  import type { VroomChartProps } from './types';
26
40
  import './jsi.d';
27
41
 
42
+ function isSkImage(frame: ChartFrame): frame is SkImage {
43
+ return typeof (frame as SkImage).getImageInfo === 'function';
44
+ }
45
+
28
46
  /**
29
47
  * Skia-rendered candlestick chart. Pass OHLCV `candles` and size it via `style`
30
48
  * (it fills its parent by default). Pan to scroll, pinch to zoom, drag the
@@ -46,6 +64,7 @@ export function VroomChart(props: VroomChartProps) {
46
64
  chartType,
47
65
  transitionMs,
48
66
  transitionEasing,
67
+ intervalTransition,
49
68
  theme,
50
69
  rsi,
51
70
  macd,
@@ -92,16 +111,39 @@ export function VroomChart(props: VroomChartProps) {
92
111
  [priceLines, priceLinesStyle, onPriceLineClose],
93
112
  );
94
113
 
95
- // RN-Skia's recorder reads this SharedValue on the UI/render runtime, a beat
96
- // behind JS-thread writes. If it ever reads null it throws ("Invalid prop
97
- // value for SkTextBlob received" — RN-Skia's mislabeled SkPicture error), so
98
- // we seed it with an empty picture and *never* assign null into it.
114
+ // RN-Skia's recorder reads these SharedValues on the UI/render runtime, a
115
+ // beat behind JS-thread writes. If it ever reads null it throws ("Invalid
116
+ // prop value for SkTextBlob received" — RN-Skia's mislabeled SkPicture
117
+ // error), so we seed them and *never* assign null. Android writes the image
118
+ // SV (raster path); iOS writes the picture SV. The unused layer stays a
119
+ // transparent 1×1 so it doesn't cover the other.
99
120
  const emptyPicture = useMemo(() => {
100
121
  const rec = Skia.PictureRecorder();
101
122
  rec.beginRecording(Skia.XYWHRect(0, 0, 1, 1));
102
123
  return rec.finishRecordingAsPicture();
103
124
  }, []);
125
+ const emptyImage = useMemo(() => {
126
+ const data = Skia.Data.fromBytes(new Uint8Array(4));
127
+ return Skia.Image.MakeImage(
128
+ {
129
+ width: 1,
130
+ height: 1,
131
+ colorType: ColorType.RGBA_8888,
132
+ alphaType: AlphaType.Premul,
133
+ },
134
+ data,
135
+ 4,
136
+ )!;
137
+ }, []);
104
138
  const pictureSV = useSharedValue<SkPicture>(emptyPicture);
139
+ const imageSV = useSharedValue<SkImage>(emptyImage);
140
+ const applyFrame = useCallback(
141
+ (frame: ChartFrame) => {
142
+ if (isSkImage(frame)) imageSV.value = frame;
143
+ else pictureSV.value = frame;
144
+ },
145
+ [imageSV, pictureSV],
146
+ );
105
147
 
106
148
  // An OS reduced-motion preference snaps every transition, the way
107
149
  // prefers-reduced-motion does on web.
@@ -111,15 +153,15 @@ export function VroomChart(props: VroomChartProps) {
111
153
  // capture) but repaints every frame, so it writes straight into the SV rather
112
154
  // than through React state — the same bypass the gesture handlers use.
113
155
  const onFrame = useCallback(
114
- (p: SkPicture) => {
115
- pictureSV.value = p;
156
+ (p: ChartFrame) => {
157
+ applyFrame(p);
116
158
  },
117
- [pictureSV],
159
+ [applyFrame],
118
160
  );
119
161
 
120
162
  const { handle, picture, volumeCollapseRef } = useChartCore(
121
163
  candles,
122
- { width, height },
164
+ { width, height, pxRatio: PixelRatio.get() },
123
165
  visibleRange,
124
166
  defaultCandleWidth,
125
167
  chartType,
@@ -131,7 +173,7 @@ export function VroomChart(props: VroomChartProps) {
131
173
  bollingerBands,
132
174
  volume,
133
175
  priceLinesProp,
134
- { seriesKey, transitionMs, transitionEasing, reduceMotion, onFrame },
176
+ { seriesKey, transitionMs, transitionEasing, intervalTransition, reduceMotion, onFrame },
135
177
  );
136
178
 
137
179
  // When the crosshair is showing, pan moves it (instead of scrolling) and
@@ -166,11 +208,11 @@ export function VroomChart(props: VroomChartProps) {
166
208
  animRaf.current = null;
167
209
  if (!handle) return;
168
210
  const next = handle.render();
169
- if (next) pictureSV.value = next;
211
+ if (next) applyFrame(next);
170
212
  if (handle.isAnimating()) {
171
213
  animRaf.current = requestAnimationFrame(animTick);
172
214
  }
173
- }, [handle, pictureSV]);
215
+ }, [handle, applyFrame]);
174
216
  const maybeStartAnim = useCallback(() => {
175
217
  if (animRaf.current != null) return;
176
218
  if (!handle?.isAnimating()) return;
@@ -194,9 +236,9 @@ export function VroomChart(props: VroomChartProps) {
194
236
  // pulse on needs — otherwise the ring wouldn't move until you touched the
195
237
  // chart.
196
238
  useEffect(() => {
197
- if (picture) pictureSV.value = picture;
239
+ if (picture) applyFrame(picture);
198
240
  maybeStartAnim();
199
- }, [picture, pictureSV, maybeStartAnim]);
241
+ }, [picture, applyFrame, maybeStartAnim]);
200
242
 
201
243
  // Candle↔line morph. When `chartType` changes we drive the core per-frame with
202
244
  // a (collapse, fade) blend and push a fresh picture into the SV each frame — the
@@ -225,7 +267,7 @@ export function VroomChart(props: VroomChartProps) {
225
267
  morphFade.current = target;
226
268
  handle.setChartType(target);
227
269
  const p = handle.render();
228
- if (p) pictureSV.value = p;
270
+ if (p) applyFrame(p);
229
271
  maybeStartAnim();
230
272
  return undefined;
231
273
  }
@@ -243,7 +285,7 @@ export function VroomChart(props: VroomChartProps) {
243
285
  morphFade.current = target;
244
286
  handle.setChartType(target);
245
287
  const p = handle.render();
246
- if (p) pictureSV.value = p;
288
+ if (p) applyFrame(p);
247
289
  maybeStartAnim();
248
290
  return undefined;
249
291
  }
@@ -258,7 +300,7 @@ export function VroomChart(props: VroomChartProps) {
258
300
  // Reduced motion still crossfades, but skips the vertical collapse.
259
301
  handle.setMorph(reduceMotion ? 0 : fade, fade);
260
302
  const p = handle.render();
261
- if (p) pictureSV.value = p;
303
+ if (p) applyFrame(p);
262
304
  if (prog < 1) {
263
305
  morphRaf.current = requestAnimationFrame(step);
264
306
  } else {
@@ -266,7 +308,7 @@ export function VroomChart(props: VroomChartProps) {
266
308
  morphFade.current = target;
267
309
  handle.setChartType(target); // lock the exact endpoint
268
310
  const q = handle.render();
269
- if (q) pictureSV.value = q;
311
+ if (q) applyFrame(q);
270
312
  maybeStartAnim();
271
313
  }
272
314
  };
@@ -279,9 +321,9 @@ export function VroomChart(props: VroomChartProps) {
279
321
  }
280
322
  };
281
323
  // maybeStartAnim is memoized on [handle, animTick] and animTick on
282
- // [handle, pictureSV], both already deps here — so it adds no new restarts
324
+ // [handle, applyFrame], both already deps here — so it adds no new restarts
283
325
  // of this clock.
284
- }, [handle, chartType, transitionMs, reduceMotion, pictureSV, maybeStartAnim]);
326
+ }, [handle, chartType, transitionMs, reduceMotion, applyFrame, maybeStartAnim]);
285
327
 
286
328
  // Volume-bar collapse. The core staggers the bars itself — tallest falling
287
329
  // first, all landing together — so unlike the loop above this one hands it
@@ -321,7 +363,7 @@ export function VroomChart(props: VroomChartProps) {
321
363
  volumeCollapseRef.current = { t: target, easing };
322
364
  handle.setVolumeCollapse(target, easing);
323
365
  const p = handle.render();
324
- if (p) pictureSV.value = p;
366
+ if (p) applyFrame(p);
325
367
  maybeStartAnim();
326
368
  return undefined;
327
369
  }
@@ -338,7 +380,7 @@ export function VroomChart(props: VroomChartProps) {
338
380
  volumeCollapseRef.current = { t, easing: kind };
339
381
  handle.setVolumeCollapse(t, kind);
340
382
  const p = handle.render();
341
- if (p) pictureSV.value = p;
383
+ if (p) applyFrame(p);
342
384
  if (prog < 1) {
343
385
  volumeRaf.current = requestAnimationFrame(step);
344
386
  } else {
@@ -359,7 +401,7 @@ export function VroomChart(props: VroomChartProps) {
359
401
  volume?.enabled,
360
402
  transitionMs,
361
403
  reduceMotion,
362
- pictureSV,
404
+ applyFrame,
363
405
  volumeCollapseRef,
364
406
  maybeStartAnim,
365
407
  ]);
@@ -386,7 +428,7 @@ export function VroomChart(props: VroomChartProps) {
386
428
  axisCollapse.current = { y: targetY, x: targetX };
387
429
  handle.setAxisCollapse(targetY, targetX);
388
430
  const p = handle.render();
389
- if (p) pictureSV.value = p;
431
+ if (p) applyFrame(p);
390
432
  maybeStartAnim();
391
433
  return undefined;
392
434
  }
@@ -408,7 +450,7 @@ export function VroomChart(props: VroomChartProps) {
408
450
  axisCollapse.current = { y: targetY, x: targetX };
409
451
  handle.setAxisCollapse(targetY, targetX);
410
452
  const p = handle.render();
411
- if (p) pictureSV.value = p;
453
+ if (p) applyFrame(p);
412
454
  maybeStartAnim();
413
455
  return undefined;
414
456
  }
@@ -425,7 +467,7 @@ export function VroomChart(props: VroomChartProps) {
425
467
  axisCollapse.current = { y, x };
426
468
  handle.setAxisCollapse(y, x);
427
469
  const p = handle.render();
428
- if (p) pictureSV.value = p;
470
+ if (p) applyFrame(p);
429
471
  if (prog < 1) {
430
472
  axisRaf.current = requestAnimationFrame(step);
431
473
  } else {
@@ -447,7 +489,7 @@ export function VroomChart(props: VroomChartProps) {
447
489
  showXAxis,
448
490
  transitionMs,
449
491
  reduceMotion,
450
- pictureSV,
492
+ applyFrame,
451
493
  maybeStartAnim,
452
494
  ]);
453
495
 
@@ -516,7 +558,7 @@ export function VroomChart(props: VroomChartProps) {
516
558
  priceDrag.current = { index: pl.index, id: pl.line.id, price: pl.line.price };
517
559
  handle.setPriceLineDrag(pl.index, pl.line.price);
518
560
  const p = handle.render();
519
- if (p) pictureSV.value = p;
561
+ if (p) applyFrame(p);
520
562
  }
521
563
  }
522
564
  })
@@ -546,7 +588,7 @@ export function VroomChart(props: VroomChartProps) {
546
588
  // scrolling. Vertical line tracks the finger x; the dot/horizontal line
547
589
  // stay lifted `crosshairOffset` px above the fingertip.
548
590
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
549
- if (ch) pictureSV.value = ch;
591
+ if (ch) applyFrame(ch);
550
592
  // The line follows the finger every frame (above), but only notify the
551
593
  // host when the snapped slot actually changes. The slot has a timeMs
552
594
  // even in the empty space ahead of the last candle, where candle=null.
@@ -564,7 +606,7 @@ export function VroomChart(props: VroomChartProps) {
564
606
  // (axes follow). Diagonal works naturally.
565
607
  next = handle.translate(e.changeX, e.changeY);
566
608
  }
567
- if (next) pictureSV.value = next;
609
+ if (next) applyFrame(next);
568
610
  maybeStartAnim();
569
611
  })
570
612
  .onEnd((e) => {
@@ -577,7 +619,7 @@ export function VroomChart(props: VroomChartProps) {
577
619
  priceDrag.current = null;
578
620
  handle.setPriceLineDrag(-1, 0);
579
621
  const p = handle.render();
580
- if (p) pictureSV.value = p;
622
+ if (p) applyFrame(p);
581
623
  if (g) onPriceLineDragEnd?.(g.id, g.price);
582
624
  return;
583
625
  }
@@ -606,7 +648,7 @@ export function VroomChart(props: VroomChartProps) {
606
648
  velocity *= Math.pow(0.5, dt / HALF_LIFE_S);
607
649
  const dx = velocity * dt;
608
650
  const next = handle.pan(dx, 0);
609
- if (next) pictureSV.value = next;
651
+ if (next) applyFrame(next);
610
652
  maybeStartAnim();
611
653
 
612
654
  if (Math.abs(velocity) > MIN_STOP) {
@@ -680,7 +722,7 @@ export function VroomChart(props: VroomChartProps) {
680
722
  if (frameX === 1 && frameY === 1) return;
681
723
 
682
724
  const next = handle.zoom(frameX, frameY, focalX, focalY);
683
- if (next) pictureSV.value = next;
725
+ if (next) applyFrame(next);
684
726
  maybeStartAnim();
685
727
  });
686
728
 
@@ -699,7 +741,7 @@ export function VroomChart(props: VroomChartProps) {
699
741
  cancelDecay();
700
742
  crosshairActive.current = true;
701
743
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
702
- if (ch) pictureSV.value = ch;
744
+ if (ch) applyFrame(ch);
703
745
  const info = handle.getCrosshairInfo();
704
746
  lastCrosshairTime.current = info?.timeMs ?? null;
705
747
  onCrosshair?.({
@@ -729,7 +771,7 @@ export function VroomChart(props: VroomChartProps) {
729
771
  if (hitAxis(e.x, e.y) !== 'chart') return;
730
772
  crosshairActive.current = false;
731
773
  const ch = handle.clearCrosshair();
732
- if (ch) pictureSV.value = ch;
774
+ if (ch) applyFrame(ch);
733
775
  lastCrosshairTime.current = null;
734
776
  onCrosshair?.({ active: false, candle: null, timeMs: null, price: null, reason: 'hide' });
735
777
  });
@@ -749,9 +791,17 @@ export function VroomChart(props: VroomChartProps) {
749
791
  <View style={{ flex: 1 }}>
750
792
  <Canvas style={{ flex: 1 }}>
751
793
  {width > 0 && height > 0 ? (
752
- // pictureSV is always a valid picture (seeded empty, never null),
753
- // so RN-Skia's UI-thread reader never sees null.
754
- <Picture picture={pictureSV} />
794
+ <>
795
+ <Picture picture={pictureSV} />
796
+ <Image
797
+ image={imageSV}
798
+ x={0}
799
+ y={0}
800
+ width={width}
801
+ height={height}
802
+ fit="fill"
803
+ />
804
+ </>
755
805
  ) : null}
756
806
  </Canvas>
757
807
  </View>
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ export type {
22
22
  VolumeConfig,
23
23
  ChartType,
24
24
  TransitionEasing,
25
+ IntervalTransition,
25
26
  PriceLine,
26
27
  PriceLinesStyle,
28
+ DefaultDrawingStyle,
27
29
  } from './types';
package/src/jsi.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // Ambient declaration for the JSI HostObject installed by VroomChartModule.
2
2
 
3
- import type { SkPicture } from '@shopify/react-native-skia';
3
+ import type { SkImage, SkPicture } from '@shopify/react-native-skia';
4
+ import type { IntervalTransition } from '@vroomchart/types';
5
+
6
+ /** Frame the JSI handle returns: an SkPicture on iOS, an SkImage on Android. */
7
+ export type ChartFrame = SkPicture | SkImage;
4
8
 
5
9
  export interface ChartHandle {
6
10
  setCandles(buffer: ArrayBuffer): void;
@@ -62,15 +66,12 @@ export interface ChartHandle {
62
66
  */
63
67
  preservePriceEnvelope(prevLow: number, prevHigh: number): void;
64
68
  /**
65
- * Capture the visible candle geometry so the next data swap can animate as a
66
- * reshape rather than a jump: each candle's wick and body slide and stretch
67
- * into the shape of its counterpart in the new data.
68
- *
69
- * Candles are paired by *slot* — position counting back from the right edge of
70
- * the visible window, which a timeframe switch preserves. Call before
71
- * setCandles, then drive setIntervalMorph from 0 to 1.
69
+ * Capture the visible candle geometry so the next data swap can animate.
70
+ * `'transform'` (default) lerps each slot into its counterpart; `'fade'`
71
+ * fades the capture out then the new scene in. Call before setCandles,
72
+ * then drive setIntervalMorph from 0 to 1.
72
73
  */
73
- beginIntervalMorph(): void;
74
+ beginIntervalMorph(mode?: IntervalTransition): void;
74
75
  /**
75
76
  * Advance the interval morph started by beginIntervalMorph. `t` (clamped to
76
77
  * 0..1) is the eased progress: 0 renders the captured geometry pixel-
@@ -79,30 +80,30 @@ export interface ChartHandle {
79
80
  */
80
81
  setIntervalMorph(t: number): void;
81
82
  /** Shifts the visible range by `dx`/`dy` pixels and returns a fresh picture. */
82
- pan(dx: number, dy: number): SkPicture | null;
83
+ pan(dx: number, dy: number): ChartFrame | null;
83
84
  /**
84
85
  * Two-finger translation: shifts the time window AND the price bounds
85
86
  * without rescaling. dy > 0 (drag down) moves content down.
86
87
  */
87
- translate(dx: number, dy: number): SkPicture | null;
88
+ translate(dx: number, dy: number): ChartFrame | null;
88
89
  /**
89
90
  * Directional zoom by per-axis multiplicative factors around focus point
90
91
  * (`fx`, `fy`) in pixels. `scaleX` resizes the time window (>1 = wider
91
92
  * candles); `scaleY` resizes the price range (>1 = taller candles). Pass 1
92
93
  * for an axis to leave it untouched.
93
94
  */
94
- zoom(scaleX: number, scaleY: number, fx: number, fy: number): SkPicture | null;
95
+ zoom(scaleX: number, scaleY: number, fx: number, fy: number): ChartFrame | null;
95
96
  /**
96
97
  * Drag-on-y-axis price scaling. `dy > 0` widens the price range
97
98
  * (candles shrink). Pivots around the price-range center.
98
99
  */
99
- scalePriceAxis(dy: number): SkPicture | null;
100
+ scalePriceAxis(dy: number): ChartFrame | null;
100
101
  /**
101
102
  * Drag-on-x-axis time scaling. `dx > 0` widens the time window
102
103
  * (candles thin). Pivots around the right edge so the most recent
103
104
  * visible candle stays in place.
104
105
  */
105
- scaleTimeAxis(dx: number): SkPicture | null;
106
+ scaleTimeAxis(dx: number): ChartFrame | null;
106
107
  /**
107
108
  * Current axis dimensions in pixels for hit testing in JS gestures.
108
109
  * `indicatorHeight` is the below-chart indicator pane height (0 when none).
@@ -117,9 +118,9 @@ export interface ChartHandle {
117
118
  * `y` should already be lifted above the touch point so the dot/horizontal
118
119
  * line aren't hidden under the thumb.
119
120
  */
120
- setCrosshair(x: number, y: number): SkPicture | null;
121
+ setCrosshair(x: number, y: number): ChartFrame | null;
121
122
  /** Hides the crosshair and returns a fresh picture. */
122
- clearCrosshair(): SkPicture | null;
123
+ clearCrosshair(): ChartFrame | null;
123
124
  /**
124
125
  * OHLCV of the candle the crosshair currently snaps to, or null when the
125
126
  * crosshair is inactive / there are no visible candles. Cheap to poll at
@@ -326,7 +327,7 @@ export interface ChartHandle {
326
327
  setPriceLineDrag(index: number, price: number): void;
327
328
  /** True while any axis-label fade is still in progress. Drives a RAF loop. */
328
329
  isAnimating(): boolean;
329
- render(): SkPicture | null;
330
+ render(): ChartFrame | null;
330
331
  }
331
332
 
332
333
  export interface VroomChartJSI {
package/src/types.ts CHANGED
@@ -20,8 +20,10 @@ export type {
20
20
  MACDConfig,
21
21
  ChartType,
22
22
  TransitionEasing,
23
+ IntervalTransition,
23
24
  PriceLine,
24
25
  PriceLinesStyle,
26
+ DefaultDrawingStyle,
25
27
  } from '@vroomchart/types';
26
28
 
27
29
  /**
@@ -1,12 +1,11 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { MutableRefObject } from 'react';
3
- import type { SkPicture } from '@shopify/react-native-skia';
4
3
 
5
4
  import NativeVroomChart from './NativeVroomChart';
6
5
  import type { DataTransition } from './dataTransitions';
7
6
  import { classifyTransition, inferStepMs, timeframeWindow } from './dataTransitions';
8
7
  import { ease } from './easing';
9
- import type { ChartHandle } from './jsi.d';
8
+ import type { ChartFrame, ChartHandle } from './jsi.d';
10
9
  import { packCandles } from './packCandles';
11
10
  import { applyTheme, parseColor, FLOAT_LINE_TIP_PULSE } from './theme';
12
11
  import type {
@@ -19,6 +18,7 @@ import type {
19
18
  PriceLinesStyle,
20
19
  RSIConfig,
21
20
  TransitionEasing,
21
+ IntervalTransition,
22
22
  VisibleRange,
23
23
  VolumeConfig,
24
24
  VroomTheme,
@@ -230,16 +230,18 @@ export type TransitionOptions = {
230
230
  transitionMs?: number;
231
231
  /** Curve applied to the morph's progress. Default 'ease-in-out'. */
232
232
  transitionEasing?: TransitionEasing;
233
+ /** `'transform'` (default) slot-lerps; `'fade'` fades out then in. */
234
+ intervalTransition?: IntervalTransition;
233
235
  /** OS reduced-motion preference: skips the capture and snaps. */
234
236
  reduceMotion?: boolean;
235
237
  /** Receives every morph frame. Without one, data swaps snap. */
236
- onFrame?: (picture: SkPicture) => void;
238
+ onFrame?: (picture: ChartFrame) => void;
237
239
  };
238
240
 
239
241
  export type ChartCoreState = {
240
242
  handle: ChartHandle | null;
241
243
  /** Picture freshly rendered after the latest data/size/range push. */
242
- picture: SkPicture | null;
244
+ picture: ChartFrame | null;
243
245
  /**
244
246
  * The last volume collapse handed to the core, or null before the first push.
245
247
  * VroomChart's animation loop owns this — it lives here only so the data effect
@@ -282,7 +284,7 @@ export function useChartCore(
282
284
  seriesKey?: string;
283
285
  } | null>(null);
284
286
  const intervalMorphRaf = useRef<number | null>(null);
285
- const [picture, setPicture] = useState<SkPicture | null>(null);
287
+ const [picture, setPicture] = useState<ChartFrame | null>(null);
286
288
 
287
289
  if (!handleRef.current && size.width > 0 && size.height > 0) {
288
290
  ensureInstalled();
@@ -296,11 +298,13 @@ export function useChartCore(
296
298
  ms: number;
297
299
  easing: TransitionEasing | undefined;
298
300
  reduceMotion: boolean;
299
- }>({ ms: 300, easing: undefined, reduceMotion: false });
301
+ interval: IntervalTransition;
302
+ }>({ ms: 300, easing: undefined, reduceMotion: false, interval: 'transform' });
300
303
  animRef.current = {
301
304
  ms: Math.max(0, transition?.transitionMs ?? 300),
302
305
  easing: transition?.transitionEasing,
303
306
  reduceMotion: transition?.reduceMotion ?? false,
307
+ interval: transition?.intervalTransition === 'fade' ? 'fade' : 'transform',
304
308
  };
305
309
  const onFrameRef = useRef(transition?.onFrame);
306
310
  onFrameRef.current = transition?.onFrame;
@@ -420,7 +424,7 @@ export function useChartCore(
420
424
  onFrameRef.current != null;
421
425
  if (morphing) {
422
426
  endIntervalMorph();
423
- h.beginIntervalMorph();
427
+ h.beginIntervalMorph(animRef.current.interval);
424
428
  }
425
429
  } else if (transitionKind === 'initial' || transitionKind === 'reset') {
426
430
  // Wholesale reframing — the slot pairing no longer holds, so land any