react-native-vroom-chart 0.14.0 → 0.16.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.
Files changed (47) hide show
  1. package/cpp/VroomChartHostObject.cpp +289 -2
  2. package/cpp/_core_include/vroom/vroom_chart.h +221 -4
  3. package/cpp/_core_src/atr.cpp +67 -0
  4. package/cpp/_core_src/atr.h +44 -0
  5. package/cpp/_core_src/atr_pane.cpp +162 -0
  6. package/cpp/_core_src/atr_pane.h +48 -0
  7. package/cpp/_core_src/chart.cpp +477 -172
  8. package/cpp/_core_src/chart.h +173 -1
  9. package/cpp/_core_src/chart_facade.cpp +317 -23
  10. package/cpp/_core_src/fair_value_gaps.cpp +76 -0
  11. package/cpp/_core_src/fair_value_gaps.h +54 -0
  12. package/cpp/_core_src/footprints.cpp +226 -0
  13. package/cpp/_core_src/footprints.h +45 -0
  14. package/cpp/_core_src/footprints_layout.cpp +140 -0
  15. package/cpp/_core_src/footprints_layout.h +94 -0
  16. package/cpp/_core_src/fvg_overlay.cpp +262 -0
  17. package/cpp/_core_src/fvg_overlay.h +43 -0
  18. package/cpp/_core_src/ichimoku.cpp +65 -0
  19. package/cpp/_core_src/ichimoku.h +45 -0
  20. package/cpp/_core_src/labels.cpp +3 -0
  21. package/cpp/_core_src/line_morph.h +159 -0
  22. package/cpp/_core_src/ma_overlay.cpp +259 -36
  23. package/cpp/_core_src/ma_overlay.h +57 -2
  24. package/cpp/_core_src/macd.cpp +24 -1
  25. package/cpp/_core_src/macd.h +16 -0
  26. package/cpp/_core_src/macd_pane.cpp +71 -62
  27. package/cpp/_core_src/macd_pane.h +13 -1
  28. package/cpp/_core_src/pane_series.h +169 -0
  29. package/cpp/_core_src/rsi.cpp +4 -0
  30. package/cpp/_core_src/rsi.h +7 -0
  31. package/cpp/_core_src/rsi_pane.cpp +85 -29
  32. package/cpp/_core_src/rsi_pane.h +11 -1
  33. package/cpp/_core_src/tip_pulse.h +4 -4
  34. package/cpp/_core_src/viewport.h +35 -0
  35. package/lib/index.d.mts +385 -3
  36. package/lib/index.d.ts +385 -3
  37. package/lib/index.js +305 -7
  38. package/lib/index.js.map +1 -1
  39. package/lib/index.mjs +305 -7
  40. package/lib/index.mjs.map +1 -1
  41. package/package.json +1 -1
  42. package/src/VroomChart.tsx +108 -7
  43. package/src/dataTransitions.ts +47 -0
  44. package/src/index.ts +10 -0
  45. package/src/jsi.d.ts +136 -1
  46. package/src/types.ts +10 -0
  47. package/src/useChartCore.ts +330 -5
@@ -3,22 +3,34 @@ import type { MutableRefObject } from 'react';
3
3
 
4
4
  import NativeVroomChart from './NativeVroomChart';
5
5
  import type { DataTransition } from './dataTransitions';
6
- import { classifyTransition, inferStepMs, timeframeWindow } from './dataTransitions';
6
+ import {
7
+ classifyStream,
8
+ classifyTransition,
9
+ inferStepMs,
10
+ isPinnedToLatest,
11
+ timeframeWindow,
12
+ } from './dataTransitions';
7
13
  import { ease } from './easing';
8
14
  import type { ChartFrame, ChartHandle } from './jsi.d';
9
15
  import { packCandles } from './packCandles';
10
16
  import { applyTheme, parseColor, FLOAT_LINE_TIP_PULSE } from './theme';
11
17
  import type {
12
18
  BollingerBandsConfig,
19
+ ATRConfig,
13
20
  Candle,
14
21
  ChartType,
22
+ FairValueGapsConfig,
23
+ IchimokuConfig,
15
24
  MACDConfig,
16
25
  MovingAverageOverlay,
17
26
  PriceLine,
18
27
  PriceLinesStyle,
28
+ Footprint,
29
+ FootprintsStyle,
19
30
  RSIConfig,
20
31
  TransitionEasing,
21
32
  IntervalTransition,
33
+ StreamTransition,
22
34
  VisibleRange,
23
35
  VolumeConfig,
24
36
  VroomTheme,
@@ -36,6 +48,9 @@ const MA_SOURCES = [
36
48
  'ohlc4',
37
49
  ] as const;
38
50
 
51
+ // Mirrors vroom::atr::Smoothing order in packages/core/src/atr.h.
52
+ const ATR_SMOOTHINGS = ['rma', 'sma', 'ema'] as const;
53
+
39
54
  // An unset style color marshals as the core's transparent inherit sentinel.
40
55
  const inheritColor = (v: string | number | undefined): number =>
41
56
  (v != null ? parseColor(v) : null) ?? 0;
@@ -67,6 +82,7 @@ function rsiToSpec(cfg: RSIConfig | undefined) {
67
82
  maWidth: cfg?.maWidth ?? -1,
68
83
  bandColor: inheritColor(cfg?.bandColor),
69
84
  bandsVisible: cfg?.bandsVisible ?? true,
85
+ extremeFill: cfg?.extremeFill ?? true,
70
86
  };
71
87
  }
72
88
 
@@ -108,6 +124,89 @@ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
108
124
  };
109
125
  }
110
126
 
127
+ // Ichimoku defaults. Green and red do double duty: they color span A and kijun,
128
+ // and tint the cloud for whichever span is on top.
129
+ const DEFAULT_ICH_GREEN = 0xff26a69a;
130
+ const DEFAULT_ICH_RED = 0xffef5350;
131
+ const DEFAULT_ICH_BLUE = 0xff2962ff;
132
+ const DEFAULT_ICH_ORANGE = 0xffff6d00;
133
+ const DEFAULT_ICH_TEAL = 0xff00bcd4;
134
+
135
+ function ichimokuToSpec(cfg: IchimokuConfig | undefined) {
136
+ const color = (v: string | number | undefined, fallback: number) =>
137
+ (v != null ? parseColor(v) : null) ?? fallback;
138
+ return {
139
+ enabled: cfg?.enabled ?? false,
140
+ tenkanPeriod: cfg?.tenkanPeriod ?? 9,
141
+ kijunPeriod: cfg?.kijunPeriod ?? 26,
142
+ senkouBPeriod: cfg?.senkouBPeriod ?? 52,
143
+ displacement: cfg?.displacement ?? 26,
144
+ tenkanColor: color(cfg?.tenkanColor, DEFAULT_ICH_BLUE),
145
+ tenkanWidth: cfg?.tenkanWidth ?? 1,
146
+ tenkanEnabled: cfg?.tenkanVisible ?? true,
147
+ kijunColor: color(cfg?.kijunColor, DEFAULT_ICH_RED),
148
+ kijunWidth: cfg?.kijunWidth ?? 1,
149
+ kijunEnabled: cfg?.kijunVisible ?? true,
150
+ senkouAColor: color(cfg?.senkouAColor, DEFAULT_ICH_GREEN),
151
+ senkouAWidth: cfg?.senkouAWidth ?? 1,
152
+ senkouAEnabled: cfg?.senkouAVisible ?? true,
153
+ senkouBColor: color(cfg?.senkouBColor, DEFAULT_ICH_ORANGE),
154
+ senkouBWidth: cfg?.senkouBWidth ?? 1,
155
+ senkouBEnabled: cfg?.senkouBVisible ?? true,
156
+ chikouColor: color(cfg?.chikouColor, DEFAULT_ICH_TEAL),
157
+ chikouWidth: cfg?.chikouWidth ?? 1,
158
+ chikouEnabled: cfg?.chikouVisible ?? true,
159
+ cloudEnabled: cfg?.cloudVisible ?? true,
160
+ bullishCloudColor: color(cfg?.bullishCloudColor, DEFAULT_ICH_GREEN),
161
+ bearishCloudColor: color(cfg?.bearishCloudColor, DEFAULT_ICH_RED),
162
+ cloudOpacity: cfg?.cloudOpacity ?? 0.15,
163
+ };
164
+ }
165
+
166
+ // Fair Value Gap defaults. The border colors fall back to the fill color, so a
167
+ // config that only restyles the fill keeps its outline in the same hue.
168
+ const DEFAULT_FVG_GREEN = 0xff26a69a;
169
+ const DEFAULT_FVG_RED = 0xffef5350;
170
+ const FVG_FILL_TYPES = ['close', 'wick'] as const;
171
+ const FVG_BORDER_STYLES = ['solid', 'dotted', 'dashed'] as const;
172
+
173
+ function fvgToSpec(cfg: FairValueGapsConfig | undefined) {
174
+ const color = (v: string | number | undefined, fallback: number) =>
175
+ (v != null ? parseColor(v) : null) ?? fallback;
176
+ const bullish = color(cfg?.bullishColor, DEFAULT_FVG_GREEN);
177
+ const bearish = color(cfg?.bearishColor, DEFAULT_FVG_RED);
178
+ return {
179
+ enabled: cfg?.enabled ?? false,
180
+ maxBarsBack: cfg?.maxBarsBack ?? 300,
181
+ waitForClose: cfg?.waitForClose ?? false,
182
+ fillType: Math.max(0, FVG_FILL_TYPES.indexOf(cfg?.fillType ?? 'close')),
183
+ deleteAfterFill: cfg?.deleteAfterFill ?? true,
184
+ extendBoxes: cfg?.extendBoxes ?? false,
185
+ boxLength: cfg?.boxLength ?? 20,
186
+ bullishColor: bullish,
187
+ bearishColor: bearish,
188
+ opacity: cfg?.opacity ?? 0.15,
189
+ borderEnabled: cfg?.borderVisible ?? true,
190
+ borderStyle: Math.max(
191
+ 0,
192
+ FVG_BORDER_STYLES.indexOf(cfg?.borderStyle ?? 'solid'),
193
+ ),
194
+ borderWidth: cfg?.borderWidth ?? 1,
195
+ bullishBorderColor: color(cfg?.bullishBorderColor, bullish),
196
+ bearishBorderColor: color(cfg?.bearishBorderColor, bearish),
197
+ labelsEnabled: cfg?.showLabels ?? true,
198
+ label: cfg?.label ?? 'FVG',
199
+ labelDistance: cfg?.labelDistance ?? 10,
200
+ // Alpha 0 is the core's "inherit the border color" sentinel.
201
+ labelColor: color(cfg?.labelColor, 0),
202
+ labelFontSize: cfg?.labelFontSize ?? 0,
203
+ showInverse: cfg?.showInverse ?? false,
204
+ inverseBullishColor: color(cfg?.inverseBullishColor, bullish),
205
+ inverseBearishColor: color(cfg?.inverseBearishColor, bearish),
206
+ inverseLabel: cfg?.inverseLabel ?? 'iFVG',
207
+ };
208
+ }
209
+
111
210
  function macdToSpec(cfg: MACDConfig | undefined) {
112
211
  const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
113
212
  return {
@@ -134,6 +233,16 @@ function macdToSpec(cfg: MACDConfig | undefined) {
134
233
  };
135
234
  }
136
235
 
236
+ function atrToSpec(cfg: ATRConfig | undefined) {
237
+ return {
238
+ enabled: cfg?.enabled ?? false,
239
+ period: cfg?.period ?? 14,
240
+ smoothing: Math.max(0, ATR_SMOOTHINGS.indexOf(cfg?.smoothing ?? 'rma')),
241
+ lineColor: inheritColor(cfg?.lineColor),
242
+ lineWidth: cfg?.lineWidth ?? -1,
243
+ };
244
+ }
245
+
137
246
  // Unset style fields go down as the core's inherit sentinels (negative float,
138
247
  // transparent color) rather than as literal defaults, so the theme keys stay in
139
248
  // charge of anything the consumer didn't set.
@@ -203,6 +312,45 @@ function priceLinesToSpec(cfg: PriceLinesProp) {
203
312
  // requires them).
204
313
  const EMPTY_PRICE_LINES = priceLinesToSpec({ lines: [], hasCloseHandler: false });
205
314
 
315
+ // Footprints share the price lines' hover weight so the two widgets light up
316
+ // alike. Zeroed geometry defers to the core's own defaults.
317
+ const DEFAULT_FOOTPRINT_HOVER_BOOST = 1.25;
318
+
319
+ // Mirrors VroomFootprintSide in packages/core/include/vroom/vroom_chart.h.
320
+ const FOOTPRINT_BUY = 0;
321
+ const FOOTPRINT_SELL = 1;
322
+
323
+ // A time no real series can contain (~273,000 BCE), still comfortably inside
324
+ // int64. Parks a malformed footprint where the core will never bucket it.
325
+ const UNBUCKETABLE_MS = -8.64e15;
326
+
327
+ /** The footprints + their shared style, as the chart's props express them. */
328
+ export type FootprintsProp = {
329
+ prints: Footprint[];
330
+ style?: FootprintsStyle;
331
+ };
332
+
333
+ function footprintsToSpec(cfg: FootprintsProp) {
334
+ return {
335
+ // Index alignment is load-bearing: the core reports hits as indices into this
336
+ // array and the gesture layer maps them straight back to the consumer's
337
+ // `footprints`. So a non-finite time — which can't be bucketed and would
338
+ // reach the native side as a garbage int64 — is neutralized *in place* rather
339
+ // than filtered out, which would shift every index after it onto the wrong
340
+ // trade.
341
+ prints: cfg.prints.map((f) => ({
342
+ timeMs: Number.isFinite(f.timeMs) ? f.timeMs : UNBUCKETABLE_MS,
343
+ side: f.side === 'sell' ? FOOTPRINT_SELL : FOOTPRINT_BUY,
344
+ })),
345
+ radiusPx: cfg.style?.radius ?? 0,
346
+ gapPx: cfg.style?.gap ?? 0,
347
+ marginPx: cfg.style?.margin ?? 0,
348
+ hoverBoost: cfg.style?.hoverBoost ?? DEFAULT_FOOTPRINT_HOVER_BOOST,
349
+ };
350
+ }
351
+
352
+ const EMPTY_FOOTPRINTS = footprintsToSpec({ prints: [] });
353
+
206
354
  let installed = false;
207
355
  function ensureInstalled(): void {
208
356
  if (installed) return;
@@ -232,6 +380,10 @@ export type TransitionOptions = {
232
380
  transitionEasing?: TransitionEasing;
233
381
  /** `'transform'` (default) slot-lerps; `'fade'` fades out then in. */
234
382
  intervalTransition?: IntervalTransition;
383
+ /** `'transform'` eases live updates; `'none'` (default) snaps them. */
384
+ streamTransition?: StreamTransition;
385
+ /** Duration of the stream animation in ms. 0 snaps. Default 150. */
386
+ streamTransitionMs?: number;
235
387
  /** OS reduced-motion preference: skips the capture and snaps. */
236
388
  reduceMotion?: boolean;
237
389
  /** Receives every morph frame. Without one, data swaps snap. */
@@ -263,11 +415,15 @@ export function useChartCore(
263
415
  theme?: VroomTheme,
264
416
  rsi?: RSIConfig,
265
417
  macd?: MACDConfig,
418
+ atr?: ATRConfig,
266
419
  movingAverages?: MovingAverageOverlay[],
267
420
  vwap?: VWAPConfig,
268
421
  bollingerBands?: BollingerBandsConfig,
422
+ ichimoku?: IchimokuConfig,
423
+ fairValueGaps?: FairValueGapsConfig,
269
424
  volume?: VolumeConfig,
270
425
  priceLines?: PriceLinesProp,
426
+ footprints?: FootprintsProp,
271
427
  transition?: TransitionOptions,
272
428
  ): ChartCoreState {
273
429
  const handleRef = useRef<ChartHandle | null>(null);
@@ -284,6 +440,13 @@ export function useChartCore(
284
440
  seriesKey?: string;
285
441
  } | null>(null);
286
442
  const intervalMorphRaf = useRef<number | null>(null);
443
+ const streamRaf = useRef<number | null>(null);
444
+ // Where an in-flight stream shift is headed, so cancelling it can land there
445
+ // rather than stranding the view mid-slide.
446
+ const streamWindowRef = useRef<VisibleRange | null>(null);
447
+ // Whether that loop is the one driving the morph scalar, so settling it never
448
+ // cuts short a timeframe switch that happens to overlap.
449
+ const streamMorphRef = useRef(false);
287
450
  const [picture, setPicture] = useState<ChartFrame | null>(null);
288
451
 
289
452
  if (!handleRef.current && size.width > 0 && size.height > 0) {
@@ -299,12 +462,25 @@ export function useChartCore(
299
462
  easing: TransitionEasing | undefined;
300
463
  reduceMotion: boolean;
301
464
  interval: IntervalTransition;
302
- }>({ ms: 300, easing: undefined, reduceMotion: false, interval: 'transform' });
465
+ stream: StreamTransition;
466
+ streamMs: number;
467
+ }>({
468
+ ms: 300,
469
+ easing: undefined,
470
+ reduceMotion: false,
471
+ interval: 'transform',
472
+ stream: 'none',
473
+ streamMs: 150,
474
+ });
303
475
  animRef.current = {
304
476
  ms: Math.max(0, transition?.transitionMs ?? 300),
305
477
  easing: transition?.transitionEasing,
306
478
  reduceMotion: transition?.reduceMotion ?? false,
307
479
  interval: transition?.intervalTransition === 'fade' ? 'fade' : 'transform',
480
+ stream: transition?.streamTransition === 'transform' ? 'transform' : 'none',
481
+ // Shorter than transitionMs by default: ticks can land faster than a 300ms
482
+ // curve, and every one that does interrupts the last.
483
+ streamMs: Math.max(0, transition?.streamTransitionMs ?? 150),
308
484
  };
309
485
  const onFrameRef = useRef(transition?.onFrame);
310
486
  onFrameRef.current = transition?.onFrame;
@@ -334,12 +510,91 @@ export function useChartCore(
334
510
  intervalMorphRaf.current = requestAnimationFrame(step);
335
511
  }, []);
336
512
 
513
+ // Stops an in-flight stream animation and puts the chart somewhere coherent.
514
+ //
515
+ // A pending window shift always lands on its target: abandoned mid-slide it
516
+ // would strand the view between two bars, half a candle off the grid.
517
+ //
518
+ // `keepMorph` is for a tick restarting on top of one already running —
519
+ // beginStreamMorph blends out of the geometry currently on screen, so landing
520
+ // that geometry first would throw away the very thing it resumes from.
521
+ const settleStream = useCallback((keepMorph = false) => {
522
+ if (streamRaf.current != null) {
523
+ cancelAnimationFrame(streamRaf.current);
524
+ streamRaf.current = null;
525
+ }
526
+ const h = handleRef.current;
527
+ const target = streamWindowRef.current;
528
+ streamWindowRef.current = null;
529
+ if (target) h?.setVisibleRange(target.startMs, target.endMs);
530
+ if (streamMorphRef.current && !keepMorph) {
531
+ streamMorphRef.current = false;
532
+ h?.setIntervalMorph(1);
533
+ }
534
+ }, []);
535
+
536
+ // Runs the clock for a live update. One loop drives both halves so they land
537
+ // on the same frame.
538
+ //
539
+ // `window` is null for a plain tick; for an append it is where the view has to
540
+ // end up. The slide is measured from wherever the window is *now*, so a shift
541
+ // interrupting another continues from the current position instead of
542
+ // snapping back to the start of the last one.
543
+ const startStreamAnim = useCallback(
544
+ (h: ChartHandle, morphing: boolean, window: VisibleRange | null) => {
545
+ const { streamMs, easing } = animRef.current;
546
+ let from = window ? h.getVisibleRange() : null;
547
+ // What the previous frame left the window at. Anything else — a pan, a
548
+ // pinch — lands somewhere different, which is how the slide notices it is
549
+ // no longer the only thing moving the view and gets out of the way.
550
+ // Cheaper than teaching every gesture to cancel it, and it can't miss one.
551
+ let applied: VisibleRange | null = null;
552
+ streamWindowRef.current = window;
553
+ streamMorphRef.current = morphing;
554
+ const start = performance.now();
555
+ const step = (now: number) => {
556
+ if (from && applied) {
557
+ const now_w = h.getVisibleRange();
558
+ if (now_w.startMs !== applied.startMs || now_w.endMs !== applied.endMs) {
559
+ from = null;
560
+ streamWindowRef.current = null;
561
+ }
562
+ }
563
+ const p = Math.min(1, (now - start) / streamMs);
564
+ const e = p < 1 ? ease(easing, p) : 1;
565
+ if (morphing) h.setIntervalMorph(e);
566
+ if (from && window) {
567
+ applied = {
568
+ startMs: Math.round(from.startMs + (window.startMs - from.startMs) * e),
569
+ endMs: Math.round(from.endMs + (window.endMs - from.endMs) * e),
570
+ };
571
+ h.setVisibleRange(applied.startMs, applied.endMs);
572
+ }
573
+ const pic = h.render();
574
+ if (pic) onFrameRef.current?.(pic);
575
+ if (p < 1) {
576
+ streamRaf.current = requestAnimationFrame(step);
577
+ } else {
578
+ streamRaf.current = null;
579
+ streamWindowRef.current = null;
580
+ streamMorphRef.current = false;
581
+ }
582
+ };
583
+ streamRaf.current = requestAnimationFrame(step);
584
+ },
585
+ [],
586
+ );
587
+
337
588
  useEffect(() => {
338
589
  return () => {
339
590
  if (intervalMorphRaf.current != null) {
340
591
  cancelAnimationFrame(intervalMorphRaf.current);
341
592
  intervalMorphRaf.current = null;
342
593
  }
594
+ if (streamRaf.current != null) {
595
+ cancelAnimationFrame(streamRaf.current);
596
+ streamRaf.current = null;
597
+ }
343
598
  };
344
599
  }, []);
345
600
 
@@ -356,16 +611,24 @@ export function useChartCore(
356
611
  const themeKey = theme ? JSON.stringify(theme) : '';
357
612
  const rsiKey = rsi ? JSON.stringify(rsi) : '';
358
613
  const macdKey = macd ? JSON.stringify(macd) : '';
614
+ const atrKey = atr ? JSON.stringify(atr) : '';
359
615
  const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
360
616
  const vwapKey = vwap ? JSON.stringify(vwap) : '';
361
617
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
618
+ const ichimokuKey = ichimoku ? JSON.stringify(ichimoku) : '';
619
+ const fvgKey = fairValueGaps ? JSON.stringify(fairValueGaps) : '';
362
620
  const volumeKey = volume ? JSON.stringify(volume) : '';
363
621
  const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
622
+ const footprintsKey = footprints ? JSON.stringify(footprints) : '';
364
623
 
365
624
  useEffect(() => {
366
625
  const h = handleRef.current;
367
626
  if (!h) return;
368
627
  h.setSize(size.width, size.height, size.pxRatio ?? 1);
628
+ // Ahead of setCandles, like setDefaultCandleWidth below: the default framing
629
+ // runs inside setCandles and reserves room past the newest candle for
630
+ // Ichimoku's leading spans, so it has to already know they're coming.
631
+ h.setIchimoku(ichimokuToSpec(ichimoku));
369
632
  // Drive the initial zoom from a target candle width. Pushed once, before the
370
633
  // first setCandles (while the core window is still 0/0), and only when the
371
634
  // caller isn't explicitly controlling the range.
@@ -404,6 +667,58 @@ export function useChartCore(
404
667
  } | null = null;
405
668
  // The pre-swap candle envelope, used to scale-lock the y-axis below.
406
669
  let prevEnvelope: { low: number; high: number } | null = null;
670
+ // Set for an animated live update: whether the last bar reshapes, and
671
+ // the window an appended bar should pull the view to.
672
+ let stream: { morph: boolean; window: VisibleRange | null } | null = null;
673
+ if (transitionKind === 'stream' && prev != null && !explicit) {
674
+ const { stream: mode, streamMs, reduceMotion } = animRef.current;
675
+ const stepMs = inferStepMs(candles);
676
+ if (
677
+ mode === 'transform' &&
678
+ streamMs > 0 &&
679
+ stepMs != null &&
680
+ !reduceMotion &&
681
+ onFrameRef.current != null
682
+ ) {
683
+ const lastMs = candles[candles.length - 1].timeMs;
684
+ const prevLastMs = prev.candles[prev.candles.length - 1].timeMs;
685
+ if (classifyStream(prev.candles, candles) === 'append') {
686
+ // Pull the window along by exactly what the data advanced, so the
687
+ // series translates a whole slot and the newest bar holds its
688
+ // place on screen. Only for a view still following the newest bar
689
+ // — someone reading history keeps their window.
690
+ //
691
+ // No capture here: slots pair from the right edge, so the new bar
692
+ // would take the previous one's geometry and drag every candle
693
+ // onto its neighbour. Translating the window moves them by their
694
+ // own timestamps instead.
695
+ const w = h.getVisibleRange();
696
+ const prevStepMs = inferStepMs(prev.candles) ?? stepMs;
697
+ if (isPinnedToLatest(w, prevLastMs, prevStepMs)) {
698
+ const by = lastMs - prevLastMs;
699
+ stream = {
700
+ morph: false,
701
+ window: { startMs: w.startMs + by, endMs: w.endMs + by },
702
+ };
703
+ }
704
+ } else {
705
+ stream = { morph: true, window: null };
706
+ }
707
+ }
708
+ if (stream?.morph) {
709
+ // Keep the geometry on screen for beginStreamMorph to resume from:
710
+ // at any real tick rate most ticks interrupt the previous one, and
711
+ // that continuity is what keeps the bar from stuttering.
712
+ settleStream(true);
713
+ h.beginStreamMorph();
714
+ } else {
715
+ // An append has no use for a capture — it would pair the new bar
716
+ // with the old one's geometry and drag the whole series along.
717
+ settleStream();
718
+ }
719
+ } else if (transitionKind === 'stream') {
720
+ settleStream();
721
+ }
407
722
  if (transitionKind === 'timeframe' && prev != null) {
408
723
  const oldWindow = h.getVisibleRange();
409
724
  const oldStepMs = inferStepMs(prev.candles);
@@ -456,6 +771,10 @@ export function useChartCore(
456
771
  // Started after the new bounds are in place: the snapshot is in band
457
772
  // fractions, so frame 0 still matches the pre-switch pixels exactly.
458
773
  if (morphing) startIntervalMorph(h);
774
+ } else if (stream) {
775
+ // After setCandles, so the capture (and the window it slides from) is
776
+ // measured against the data the animation is heading toward.
777
+ startStreamAnim(h, stream.morph, stream.window);
459
778
  } else if (transitionKind === 'reset') {
460
779
  h.resetView();
461
780
  }
@@ -479,9 +798,11 @@ export function useChartCore(
479
798
  }
480
799
  h.setRSI(rsiToSpec(rsi));
481
800
  h.setMACD(macdToSpec(macd));
801
+ h.setATR(atrToSpec(atr));
482
802
  h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
483
803
  h.setVWAP(vwapToSpec(vwap));
484
804
  h.setBollinger(bollingerToSpec(bollingerBands));
805
+ h.setFairValueGaps(fvgToSpec(fairValueGaps));
485
806
  h.setVolume(volumeToSpec(volume));
486
807
  // setVolume snaps the collapse scalar to its `enabled`, which would cut a
487
808
  // toggle animation short whenever this effect re-runs (a streaming candle, a
@@ -492,6 +813,9 @@ export function useChartCore(
492
813
  h.setPriceLines(
493
814
  priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES,
494
815
  );
816
+ h.setFootprints(
817
+ footprints?.prints.length ? footprintsToSpec(footprints) : EMPTY_FOOTPRINTS,
818
+ );
495
819
  // TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
496
820
  // the VroomBand structs in the JSI handle) — web-only for now.
497
821
  // A just-started morph is already pushing frames straight to the host sink;
@@ -499,10 +823,11 @@ export function useChartCore(
499
823
  // frame 0 is pixel-identical to what's on screen, so there's nothing to show
500
824
  // in the meantime anyway.
501
825
  if (!morphing) setPicture(h.render());
502
- // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines are
503
- // represented by their *Key deps.
826
+ // theme/rsi/macd/atr/movingAverages/vwap/bollingerBands/ichimoku/
827
+ // fairValueGaps/volume/priceLines/footprints are represented by their *Key
828
+ // deps.
504
829
  // eslint-disable-next-line react-hooks/exhaustive-deps
505
- }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
830
+ }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, atrKey, maKey, vwapKey, bollingerKey, ichimokuKey, fvgKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, endIntervalMorph, startStreamAnim, settleStream]);
506
831
 
507
832
  return { handle: handleRef.current, picture, volumeCollapseRef };
508
833
  }