react-native-vroom-chart 0.7.0 → 0.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.
@@ -0,0 +1,148 @@
1
+ // Mirror of packages/react/src/dataTransitions.ts — the platform packages don't
2
+ // depend on each other, and @vroomchart/types carries types only.
3
+ //
4
+ // Classifies how a new `candles` prop relates to the previous one so the chart
5
+ // can react appropriately: leave the viewport alone for streaming updates,
6
+ // re-anchor the time window for a timeframe switch, or fully reset the view
7
+ // for a different asset. Pure functions, no React — see useChartCore for the
8
+ // orchestration.
9
+
10
+ import type { Candle, VisibleRange } from '@vroomchart/types';
11
+
12
+ /**
13
+ * How a new `candles` array relates to the one the chart already holds:
14
+ * `'initial'` is the first data, `'stream'` a live update to the same series,
15
+ * `'timeframe'` the same asset re-bucketed into a different interval, and
16
+ * `'reset'` a different series entirely.
17
+ */
18
+ export type DataTransition = 'initial' | 'stream' | 'timeframe' | 'reset';
19
+
20
+ // A step change below this ratio is treated as the same timeframe. Real steps
21
+ // are exact integer ms; the tolerance only absorbs rounding/DST quirks (the
22
+ // smallest real timeframe jump, 1m -> 2m, is 100% apart).
23
+ const STEP_TOLERANCE = 0.01;
24
+
25
+ // Same-asset check for a timeframe switch: both series end "now", so their
26
+ // last closes must be close. No asset moves 25% between two consecutive prop
27
+ // pushes; distinct assets within 25% of each other are what `seriesKey` is for.
28
+ const MAX_SAME_ASSET_CLOSE_RATIO = 1.25;
29
+
30
+ // A coarser bucketing can shift the final bar's open by up to one coarse bar;
31
+ // allow that plus an in-flight bar when checking the two series end together.
32
+ const MAX_END_DRIFT_STEPS = 3;
33
+
34
+ // Streaming pushes may batch a few bars (e.g. a throttled background tab), but
35
+ // a jump of more than this many steps means the data was re-fetched elsewhere.
36
+ const MAX_STREAM_ADVANCE_STEPS = 5;
37
+
38
+ /**
39
+ * The candle period in ms, inferred as the median of the first few intervals
40
+ * (robust to a single gap). Null when there are fewer than two candles.
41
+ */
42
+ export function inferStepMs(candles: Candle[]): number | null {
43
+ if (candles.length < 2) return null;
44
+ const k = Math.min(candles.length - 1, 8);
45
+ const diffs: number[] = [];
46
+ for (let i = 0; i < k; i++) diffs.push(candles[i + 1].timeMs - candles[i].timeMs);
47
+ diffs.sort((a, b) => a - b);
48
+ const median = diffs[Math.floor(diffs.length / 2)];
49
+ return median > 0 ? median : null;
50
+ }
51
+
52
+ // Index of the candle whose timeMs exactly equals `t`, or -1. Binary search over
53
+ // the ascending-by-time series, so it tolerates interior gaps (missing bars from
54
+ // downtime / illiquid periods) — unlike a uniform-grid index computed from the
55
+ // step, which assumes a hole-free grid.
56
+ function indexByTime(candles: Candle[], t: number): number {
57
+ let lo = 0;
58
+ let hi = candles.length - 1;
59
+ while (lo <= hi) {
60
+ const mid = (lo + hi) >>> 1;
61
+ const v = candles[mid].timeMs;
62
+ if (v === t) return mid;
63
+ if (v < t) lo = mid + 1;
64
+ else hi = mid - 1;
65
+ }
66
+ return -1;
67
+ }
68
+
69
+ /**
70
+ * Classify a candles-prop change. `prev` is the previously rendered array
71
+ * (null on first render); `seriesKeyChanged` forces `reset` regardless of the
72
+ * data (the explicit escape hatch).
73
+ *
74
+ * Constraint: detection compares two immutable snapshots. An array mutated in
75
+ * place (same reference) never reaches this code — React props must change
76
+ * identity to re-render.
77
+ */
78
+ export function classifyTransition(
79
+ prev: Candle[] | null,
80
+ next: Candle[],
81
+ seriesKeyChanged: boolean,
82
+ ): DataTransition {
83
+ if (!prev || prev.length === 0) return 'initial';
84
+ if (next.length === 0) return 'stream'; // nothing to reframe against
85
+ if (seriesKeyChanged) return 'reset';
86
+
87
+ const prevStep = inferStepMs(prev);
88
+ const nextStep = inferStepMs(next);
89
+ if (prevStep == null || nextStep == null) return 'reset'; // too little data to reason
90
+
91
+ const prevLast = prev[prev.length - 1];
92
+ const nextLast = next[next.length - 1];
93
+
94
+ if (Math.abs(nextStep - prevStep) <= prevStep * STEP_TOLERANCE) {
95
+ // Same step: streaming iff prev's last bar still appears in next (covers
96
+ // append, update-last, and rolling buffers that drop old bars from the
97
+ // front) and the series only advanced by a few bars. Locate that bar by
98
+ // timestamp, not by a step-derived index — real series have interior gaps
99
+ // (downtime / illiquid periods), so a uniform-grid index would miss it and
100
+ // misread a harmless in-place tick as a reset.
101
+ // Time alignment alone isn't enough: two assets on the same exchange share
102
+ // the bar grid, so the bar at the shared timestamp must also be (nearly) the
103
+ // same bar — update-last moves the close, but never by the same-asset ratio.
104
+ const idx = indexByTime(next, prevLast.timeMs);
105
+ const aligned = idx >= 0;
106
+ const sharedBarRatio =
107
+ aligned && next[idx].close > 0 && prevLast.close > 0
108
+ ? Math.max(next[idx].close / prevLast.close, prevLast.close / next[idx].close)
109
+ : Infinity;
110
+ const advanced =
111
+ nextLast.timeMs >= prevLast.timeMs &&
112
+ nextLast.timeMs - prevLast.timeMs <= MAX_STREAM_ADVANCE_STEPS * nextStep;
113
+ return sharedBarRatio <= MAX_SAME_ASSET_CLOSE_RATIO && advanced ? 'stream' : 'reset';
114
+ }
115
+
116
+ // Step changed: a timeframe switch iff it still looks like the same asset —
117
+ // last closes near each other and both series ending around the same time.
118
+ const closeRatio =
119
+ prevLast.close > 0 && nextLast.close > 0
120
+ ? Math.max(nextLast.close / prevLast.close, prevLast.close / nextLast.close)
121
+ : Infinity;
122
+ const prevEnd = prevLast.timeMs + prevStep;
123
+ const nextEnd = nextLast.timeMs + nextStep;
124
+ const endsTogether =
125
+ Math.abs(nextEnd - prevEnd) <= MAX_END_DRIFT_STEPS * Math.max(prevStep, nextStep);
126
+ return closeRatio <= MAX_SAME_ASSET_CLOSE_RATIO && endsTogether ? 'timeframe' : 'reset';
127
+ }
128
+
129
+ /**
130
+ * The visible window to apply after a timeframe switch so each candle keeps
131
+ * the exact pixel width it had before: the visible slot count is preserved and
132
+ * the right edge re-anchors on the newest candle (any future-gap overshoot is
133
+ * carried over in slots, clamped to the core's 3/4-window cap). The new start
134
+ * may precede the first candle — that gap is intentional, width wins.
135
+ */
136
+ export function timeframeWindow(
137
+ oldWindow: VisibleRange,
138
+ oldStepMs: number,
139
+ oldLastMs: number,
140
+ newStepMs: number,
141
+ newLastMs: number,
142
+ ): VisibleRange {
143
+ const slots = (oldWindow.endMs - oldWindow.startMs) / oldStepMs;
144
+ const offsetRaw = (oldWindow.endMs - oldLastMs) / oldStepMs;
145
+ const offsetSlots = Math.min(Math.max(offsetRaw, 0), slots * 0.75);
146
+ const endMs = Math.round(newLastMs + offsetSlots * newStepMs);
147
+ return { startMs: Math.round(endMs - slots * newStepMs), endMs };
148
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  export { VroomChart } from './VroomChart';
2
+ export {
3
+ classifyTransition,
4
+ inferStepMs,
5
+ timeframeWindow,
6
+ type DataTransition,
7
+ } from './dataTransitions';
2
8
  export type {
3
9
  VroomChartProps,
4
10
  Candle,
package/src/jsi.d.ts CHANGED
@@ -27,6 +27,57 @@ export interface ChartHandle {
27
27
  setChartType(mode: number): void;
28
28
  /** Candle↔line morph blend: collapse folds candles to close, fade crossfades. */
29
29
  setMorph(collapse: number, fade: number): void;
30
+ /** The current visible time window. {startMs: 0, endMs: 0} = uninitialized. */
31
+ getVisibleRange(): { startMs: number; endMs: number };
32
+ /**
33
+ * Reset to the fresh-mount view: frame the most recent ~80 candles and
34
+ * re-enable continuous y auto-fit (the price range follows the visible
35
+ * candles until the next manual y gesture). Use when the data series is
36
+ * wholesale replaced — e.g. switching assets.
37
+ */
38
+ resetView(): void;
39
+ /**
40
+ * Re-enable continuous y auto-fit only; the time window is untouched. Use
41
+ * after repositioning the window for a same-asset data swap (e.g. a
42
+ * timeframe switch) so the price scale re-fits the newly visible candles.
43
+ */
44
+ resetPriceScale(): void;
45
+ /**
46
+ * The visible price *envelope* — the min low / max high across the currently
47
+ * visible candles, i.e. the extent the candles occupy rather than the (wider)
48
+ * axis range. Null when no candles are visible.
49
+ */
50
+ getVisiblePriceEnvelope(): { low: number; high: number } | null;
51
+ /**
52
+ * Scale lock for a same-asset data swap that re-buckets the same price action
53
+ * into a different high-low span (a timeframe switch). Rescales a *manual*
54
+ * price range so the visible envelope keeps the exact pixel height and
55
+ * position the given pre-swap envelope had — so candles don't suddenly shrink
56
+ * or grow when the interval changes.
57
+ *
58
+ * Call after setCandles + setVisibleRange, passing the envelope read by
59
+ * getVisiblePriceEnvelope before the swap. A no-op in auto-y mode (auto-fit is
60
+ * already span-invariant); falls back to resetPriceScale when either envelope
61
+ * is degenerate.
62
+ */
63
+ preservePriceEnvelope(prevLow: number, prevHigh: number): void;
64
+ /**
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.
72
+ */
73
+ beginIntervalMorph(): void;
74
+ /**
75
+ * Advance the interval morph started by beginIntervalMorph. `t` (clamped to
76
+ * 0..1) is the eased progress: 0 renders the captured geometry pixel-
77
+ * identically to the pre-swap frame, 1 renders the new candles and releases
78
+ * the capture. Driven per-frame by the host animation loop.
79
+ */
80
+ setIntervalMorph(t: number): void;
30
81
  /** Shifts the visible range by `dx`/`dy` pixels and returns a fresh picture. */
31
82
  pan(dx: number, dy: number): SkPicture | null;
32
83
  /**
package/src/theme.ts CHANGED
@@ -28,11 +28,18 @@ export const FLOAT_KEYS: Partial<Record<keyof VroomTheme, number>> = {
28
28
  volumeRadius: 10, // VROOM_FLOAT_VOLUME_RADIUS_PX
29
29
  lineWidth: 11, // VROOM_FLOAT_LINE_WIDTH_PX
30
30
  lineGradientOpacity: 12, // VROOM_FLOAT_LINE_GRADIENT_OPACITY
31
+ lineTension: 13, // VROOM_FLOAT_LINE_TENSION
31
32
  };
32
33
 
34
+ // Named because useChartCore clears it directly under reduced motion, outside
35
+ // the theme sweep below.
36
+ export const FLOAT_LINE_TIP_PULSE = 15; // VROOM_FLOAT_LINE_TIP_PULSE
37
+
33
38
  // Maps each boolean VroomTheme field to its VroomFloatKey index (pushed as 0/1).
34
39
  export const BOOL_KEYS: Partial<Record<keyof VroomTheme, number>> = {
35
40
  wickRoundCap: 9, // VROOM_FLOAT_WICK_ROUND_CAP
41
+ lineTipDot: 14, // VROOM_FLOAT_LINE_TIP_DOT
42
+ lineTipPulse: FLOAT_LINE_TIP_PULSE,
36
43
  };
37
44
 
38
45
  // Parses a color into a packed 0xAARRGGBB integer (Skia's ARGB order).
@@ -1,11 +1,14 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { MutableRefObject } from 'react';
3
3
  import type { SkPicture } from '@shopify/react-native-skia';
4
4
 
5
5
  import NativeVroomChart from './NativeVroomChart';
6
+ import type { DataTransition } from './dataTransitions';
7
+ import { classifyTransition, inferStepMs, timeframeWindow } from './dataTransitions';
8
+ import { ease } from './easing';
6
9
  import type { ChartHandle } from './jsi.d';
7
10
  import { packCandles } from './packCandles';
8
- import { applyTheme, parseColor } from './theme';
11
+ import { applyTheme, parseColor, FLOAT_LINE_TIP_PULSE } from './theme';
9
12
  import type {
10
13
  BollingerBandsConfig,
11
14
  Candle,
@@ -15,6 +18,7 @@ import type {
15
18
  PriceLine,
16
19
  PriceLinesStyle,
17
20
  RSIConfig,
21
+ TransitionEasing,
18
22
  VisibleRange,
19
23
  VolumeConfig,
20
24
  VroomTheme,
@@ -213,6 +217,25 @@ function ensureInstalled(): void {
213
217
  /** Progress + curve of the staggered volume-bar collapse. See setVolumeCollapse. */
214
218
  export type VolumeCollapse = { t: number; easing: number };
215
219
 
220
+ /**
221
+ * How a data swap should animate, plus where its frames go. The interval morph
222
+ * has to be started from inside the data effect (it needs the pre-swap capture),
223
+ * but it repaints at 60fps — far too often for React state — so the host passes
224
+ * a sink that writes straight into the picture SharedValue.
225
+ */
226
+ export type TransitionOptions = {
227
+ /** Identity of the series; a change forces a full view reset. */
228
+ seriesKey?: string;
229
+ /** Duration of the interval morph in ms. 0 snaps. Default 300. */
230
+ transitionMs?: number;
231
+ /** Curve applied to the morph's progress. Default 'ease-in-out'. */
232
+ transitionEasing?: TransitionEasing;
233
+ /** OS reduced-motion preference: skips the capture and snaps. */
234
+ reduceMotion?: boolean;
235
+ /** Receives every morph frame. Without one, data swaps snap. */
236
+ onFrame?: (picture: SkPicture) => void;
237
+ };
238
+
216
239
  export type ChartCoreState = {
217
240
  handle: ChartHandle | null;
218
241
  /** Picture freshly rendered after the latest data/size/range push. */
@@ -243,6 +266,7 @@ export function useChartCore(
243
266
  bollingerBands?: BollingerBandsConfig,
244
267
  volume?: VolumeConfig,
245
268
  priceLines?: PriceLinesProp,
269
+ transition?: TransitionOptions,
246
270
  ): ChartCoreState {
247
271
  const handleRef = useRef<ChartHandle | null>(null);
248
272
  // Push setDefaultCandleWidth only once (first load): setCandles re-runs on
@@ -250,6 +274,14 @@ export function useChartCore(
250
274
  // so re-pushing would snap the view away from the user's pan/zoom.
251
275
  const defaultWidthAppliedRef = useRef(false);
252
276
  const volumeCollapseRef = useRef<VolumeCollapse | null>(null);
277
+ // What the core currently holds, for classifying the next data change. Keyed
278
+ // by handle so a recreated core is treated as a fresh initial load.
279
+ const prevDataRef = useRef<{
280
+ handle: ChartHandle;
281
+ candles: Candle[];
282
+ seriesKey?: string;
283
+ } | null>(null);
284
+ const intervalMorphRaf = useRef<number | null>(null);
253
285
  const [picture, setPicture] = useState<SkPicture | null>(null);
254
286
 
255
287
  if (!handleRef.current && size.width > 0 && size.height > 0) {
@@ -257,6 +289,56 @@ export function useChartCore(
257
289
  handleRef.current = globalThis.VroomChartJSI!.create();
258
290
  }
259
291
 
292
+ // Animation config and frame sink in refs, refreshed each render, so changing
293
+ // the duration, curve or callback identity doesn't re-run the data effect
294
+ // below (which would re-push every candle).
295
+ const animRef = useRef<{
296
+ ms: number;
297
+ easing: TransitionEasing | undefined;
298
+ reduceMotion: boolean;
299
+ }>({ ms: 300, easing: undefined, reduceMotion: false });
300
+ animRef.current = {
301
+ ms: Math.max(0, transition?.transitionMs ?? 300),
302
+ easing: transition?.transitionEasing,
303
+ reduceMotion: transition?.reduceMotion ?? false,
304
+ };
305
+ const onFrameRef = useRef(transition?.onFrame);
306
+ onFrameRef.current = transition?.onFrame;
307
+ const seriesKey = transition?.seriesKey;
308
+
309
+ // Stop an in-flight interval morph and land the core on the new candles.
310
+ const endIntervalMorph = useCallback(() => {
311
+ if (intervalMorphRaf.current != null) {
312
+ cancelAnimationFrame(intervalMorphRaf.current);
313
+ intervalMorphRaf.current = null;
314
+ }
315
+ handleRef.current?.setIntervalMorph(1);
316
+ }, []);
317
+
318
+ // Runs the interval morph clock. The core holds the pre-swap geometry (see
319
+ // beginIntervalMorph) and reshapes each candle slot toward its new counterpart.
320
+ const startIntervalMorph = useCallback((h: ChartHandle) => {
321
+ const { ms, easing } = animRef.current;
322
+ const start = performance.now();
323
+ const step = (now: number) => {
324
+ const p = Math.min(1, (now - start) / ms);
325
+ h.setIntervalMorph(p < 1 ? ease(easing, p) : 1);
326
+ const pic = h.render();
327
+ if (pic) onFrameRef.current?.(pic);
328
+ intervalMorphRaf.current = p < 1 ? requestAnimationFrame(step) : null;
329
+ };
330
+ intervalMorphRaf.current = requestAnimationFrame(step);
331
+ }, []);
332
+
333
+ useEffect(() => {
334
+ return () => {
335
+ if (intervalMorphRaf.current != null) {
336
+ cancelAnimationFrame(intervalMorphRaf.current);
337
+ intervalMorphRaf.current = null;
338
+ }
339
+ };
340
+ }, []);
341
+
260
342
  // When no visibleRange is provided, leave the range entirely to the C++
261
343
  // side (which defaults to a sensible recent window on first setCandles).
262
344
  // Only push setVisibleRange when the caller is actively controlling it,
@@ -292,8 +374,89 @@ export function useChartCore(
292
374
  h.setDefaultCandleWidth(defaultCandleWidth);
293
375
  defaultWidthAppliedRef.current = true;
294
376
  }
377
+ // How the new candles relate to what the core holds decides what happens to
378
+ // the viewport: a stream leaves it alone, a timeframe switch re-anchors and
379
+ // morphs into it, a different asset resets it.
380
+ let morphing = false;
295
381
  if (candles.length > 0) {
296
- h.setCandles(packCandles(candles));
382
+ const prev = prevDataRef.current;
383
+ const freshHandle = prev == null || prev.handle !== h;
384
+ if (freshHandle || prev.candles !== candles || prev.seriesKey !== seriesKey) {
385
+ // A fresh core frames itself (its window starts at 0/0); an explicit
386
+ // visibleRange prop overrides any auto behavior, so treat the change
387
+ // like a stream and let the range application below win.
388
+ const transitionKind: DataTransition = freshHandle
389
+ ? 'initial'
390
+ : explicit
391
+ ? 'stream'
392
+ : classifyTransition(prev.candles, candles, seriesKey !== prev.seriesKey);
393
+
394
+ // Capture the outgoing view before setCandles re-infers the candle
395
+ // period from the new data.
396
+ let tfArgs: {
397
+ oldWindow: VisibleRange;
398
+ oldStepMs: number;
399
+ oldLastMs: number;
400
+ } | null = null;
401
+ // The pre-swap candle envelope, used to scale-lock the y-axis below.
402
+ let prevEnvelope: { low: number; high: number } | null = null;
403
+ if (transitionKind === 'timeframe' && prev != null) {
404
+ const oldWindow = h.getVisibleRange();
405
+ const oldStepMs = inferStepMs(prev.candles);
406
+ if (oldWindow.endMs > oldWindow.startMs && oldStepMs != null) {
407
+ tfArgs = {
408
+ oldWindow,
409
+ oldStepMs,
410
+ oldLastMs: prev.candles[prev.candles.length - 1].timeMs,
411
+ };
412
+ }
413
+ prevEnvelope = h.getVisiblePriceEnvelope();
414
+ // Capture the outgoing candle geometry, but only when it will actually
415
+ // be animated so a disabled animation costs no snapshot. A switch
416
+ // during a morph restarts from the data the core currently holds.
417
+ morphing =
418
+ animRef.current.ms > 0 &&
419
+ !animRef.current.reduceMotion &&
420
+ onFrameRef.current != null;
421
+ if (morphing) {
422
+ endIntervalMorph();
423
+ h.beginIntervalMorph();
424
+ }
425
+ } else if (transitionKind === 'initial' || transitionKind === 'reset') {
426
+ // Wholesale reframing — the slot pairing no longer holds, so land any
427
+ // in-flight morph rather than reshaping into unrelated data.
428
+ endIntervalMorph();
429
+ }
430
+
431
+ h.setCandles(packCandles(candles));
432
+
433
+ if (transitionKind === 'timeframe') {
434
+ const newStepMs = inferStepMs(candles);
435
+ if (tfArgs && newStepMs != null) {
436
+ const w = timeframeWindow(
437
+ tfArgs.oldWindow,
438
+ tfArgs.oldStepMs,
439
+ tfArgs.oldLastMs,
440
+ newStepMs,
441
+ candles[candles.length - 1].timeMs,
442
+ );
443
+ h.setVisibleRange(w.startMs, w.endMs);
444
+ }
445
+ // Scale-lock the y-axis: the same price action re-buckets into a
446
+ // smaller/larger high-low span, so a manual price range is rescaled to
447
+ // keep the candle envelope at the pixel height it just had instead of
448
+ // snapping back to auto-fit. A no-op in auto-y mode, which is already
449
+ // span-invariant.
450
+ if (prevEnvelope) h.preservePriceEnvelope(prevEnvelope.low, prevEnvelope.high);
451
+ else h.resetPriceScale();
452
+ // Started after the new bounds are in place: the snapshot is in band
453
+ // fractions, so frame 0 still matches the pre-switch pixels exactly.
454
+ if (morphing) startIntervalMorph(h);
455
+ } else if (transitionKind === 'reset') {
456
+ h.resetView();
457
+ }
458
+ prevDataRef.current = { handle: h, candles, seriesKey };
459
+ }
297
460
  }
298
461
  if (explicit) {
299
462
  h.setVisibleRange(startMs, endMs);
@@ -303,6 +466,13 @@ export function useChartCore(
303
466
  if (theme) {
304
467
  applyTheme(h, theme);
305
468
  }
469
+ // The tip dot stays, only its animation drops — the same bargain the
470
+ // candle↔line morph strikes when it keeps the crossfade but skips the
471
+ // collapse. Also stops the pulse from pinning a RAF loop for a user who
472
+ // asked for less motion.
473
+ if (animRef.current.reduceMotion) {
474
+ h.setFloat(FLOAT_LINE_TIP_PULSE, 0);
475
+ }
306
476
  h.setRSI(rsiToSpec(rsi));
307
477
  h.setMACD(macdToSpec(macd));
308
478
  h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
@@ -320,11 +490,15 @@ export function useChartCore(
320
490
  );
321
491
  // TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
322
492
  // the VroomBand structs in the JSI handle) — web-only for now.
323
- setPicture(h.render());
493
+ // A just-started morph is already pushing frames straight to the host sink;
494
+ // this snapshot would land on top of them a frame or two later. The morph's
495
+ // frame 0 is pixel-identical to what's on screen, so there's nothing to show
496
+ // in the meantime anyway.
497
+ if (!morphing) setPicture(h.render());
324
498
  // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines are
325
499
  // represented by their *Key deps.
326
500
  // eslint-disable-next-line react-hooks/exhaustive-deps
327
- }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey]);
501
+ }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
328
502
 
329
503
  return { handle: handleRef.current, picture, volumeCollapseRef };
330
504
  }