react-native-vroom-chart 0.15.0 → 0.17.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 (53) hide show
  1. package/cpp/VroomChartHostObject.cpp +252 -1
  2. package/cpp/_core_include/vroom/vroom_chart.h +178 -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/candles.cpp +15 -14
  8. package/cpp/_core_src/chart.cpp +758 -200
  9. package/cpp/_core_src/chart.h +221 -3
  10. package/cpp/_core_src/chart_facade.cpp +236 -23
  11. package/cpp/_core_src/color_lerp.h +28 -0
  12. package/cpp/_core_src/fair_value_gaps.cpp +76 -0
  13. package/cpp/_core_src/fair_value_gaps.h +54 -0
  14. package/cpp/_core_src/fvg_overlay.cpp +262 -0
  15. package/cpp/_core_src/fvg_overlay.h +43 -0
  16. package/cpp/_core_src/ichimoku.cpp +65 -0
  17. package/cpp/_core_src/ichimoku.h +45 -0
  18. package/cpp/_core_src/labels.cpp +3 -0
  19. package/cpp/_core_src/line_morph.h +159 -0
  20. package/cpp/_core_src/loading_line.cpp +183 -0
  21. package/cpp/_core_src/loading_line.h +38 -0
  22. package/cpp/_core_src/loading_wave.h +140 -0
  23. package/cpp/_core_src/ma_overlay.cpp +273 -45
  24. package/cpp/_core_src/ma_overlay.h +64 -2
  25. package/cpp/_core_src/macd.cpp +24 -1
  26. package/cpp/_core_src/macd.h +16 -0
  27. package/cpp/_core_src/macd_pane.cpp +71 -62
  28. package/cpp/_core_src/macd_pane.h +13 -1
  29. package/cpp/_core_src/pane_series.h +169 -0
  30. package/cpp/_core_src/price_indicator.cpp +21 -7
  31. package/cpp/_core_src/price_indicator.h +11 -1
  32. package/cpp/_core_src/price_indicator_anim.h +81 -0
  33. package/cpp/_core_src/rsi.cpp +4 -0
  34. package/cpp/_core_src/rsi.h +7 -0
  35. package/cpp/_core_src/rsi_pane.cpp +85 -29
  36. package/cpp/_core_src/rsi_pane.h +11 -1
  37. package/cpp/_core_src/theme.cpp +5 -0
  38. package/cpp/_core_src/tip_geometry.h +55 -0
  39. package/cpp/_core_src/viewport.h +68 -0
  40. package/lib/index.d.mts +281 -3
  41. package/lib/index.d.ts +281 -3
  42. package/lib/index.js +292 -14
  43. package/lib/index.js.map +1 -1
  44. package/lib/index.mjs +292 -14
  45. package/lib/index.mjs.map +1 -1
  46. package/package.json +1 -1
  47. package/src/VroomChart.tsx +33 -3
  48. package/src/dataTransitions.ts +47 -0
  49. package/src/index.ts +5 -0
  50. package/src/jsi.d.ts +139 -1
  51. package/src/theme.ts +1 -0
  52. package/src/types.ts +5 -0
  53. package/src/useChartCore.ts +402 -8
@@ -3,15 +3,24 @@ 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,
@@ -21,6 +30,7 @@ import type {
21
30
  RSIConfig,
22
31
  TransitionEasing,
23
32
  IntervalTransition,
33
+ StreamTransition,
24
34
  VisibleRange,
25
35
  VolumeConfig,
26
36
  VroomTheme,
@@ -38,6 +48,9 @@ const MA_SOURCES = [
38
48
  'ohlc4',
39
49
  ] as const;
40
50
 
51
+ // Mirrors vroom::atr::Smoothing order in packages/core/src/atr.h.
52
+ const ATR_SMOOTHINGS = ['rma', 'sma', 'ema'] as const;
53
+
41
54
  // An unset style color marshals as the core's transparent inherit sentinel.
42
55
  const inheritColor = (v: string | number | undefined): number =>
43
56
  (v != null ? parseColor(v) : null) ?? 0;
@@ -69,6 +82,7 @@ function rsiToSpec(cfg: RSIConfig | undefined) {
69
82
  maWidth: cfg?.maWidth ?? -1,
70
83
  bandColor: inheritColor(cfg?.bandColor),
71
84
  bandsVisible: cfg?.bandsVisible ?? true,
85
+ extremeFill: cfg?.extremeFill ?? true,
72
86
  };
73
87
  }
74
88
 
@@ -110,6 +124,89 @@ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
110
124
  };
111
125
  }
112
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
+
113
210
  function macdToSpec(cfg: MACDConfig | undefined) {
114
211
  const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
115
212
  return {
@@ -136,6 +233,16 @@ function macdToSpec(cfg: MACDConfig | undefined) {
136
233
  };
137
234
  }
138
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
+
139
246
  // Unset style fields go down as the core's inherit sentinels (negative float,
140
247
  // transparent color) rather than as literal defaults, so the theme keys stay in
141
248
  // charge of anything the consumer didn't set.
@@ -273,6 +380,10 @@ export type TransitionOptions = {
273
380
  transitionEasing?: TransitionEasing;
274
381
  /** `'transform'` (default) slot-lerps; `'fade'` fades out then in. */
275
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;
276
387
  /** OS reduced-motion preference: skips the capture and snaps. */
277
388
  reduceMotion?: boolean;
278
389
  /** Receives every morph frame. Without one, data swaps snap. */
@@ -304,13 +415,19 @@ export function useChartCore(
304
415
  theme?: VroomTheme,
305
416
  rsi?: RSIConfig,
306
417
  macd?: MACDConfig,
418
+ atr?: ATRConfig,
307
419
  movingAverages?: MovingAverageOverlay[],
308
420
  vwap?: VWAPConfig,
309
421
  bollingerBands?: BollingerBandsConfig,
422
+ ichimoku?: IchimokuConfig,
423
+ fairValueGaps?: FairValueGapsConfig,
310
424
  volume?: VolumeConfig,
311
425
  priceLines?: PriceLinesProp,
312
426
  footprints?: FootprintsProp,
313
427
  transition?: TransitionOptions,
428
+ // Trails the config params because it's data state, not configuration: it
429
+ // pairs with `candles` above (see showLoadingLine below).
430
+ loading?: boolean,
314
431
  ): ChartCoreState {
315
432
  const handleRef = useRef<ChartHandle | null>(null);
316
433
  // Push setDefaultCandleWidth only once (first load): setCandles re-runs on
@@ -326,6 +443,13 @@ export function useChartCore(
326
443
  seriesKey?: string;
327
444
  } | null>(null);
328
445
  const intervalMorphRaf = useRef<number | null>(null);
446
+ const streamRaf = useRef<number | null>(null);
447
+ // Where an in-flight stream shift is headed, so cancelling it can land there
448
+ // rather than stranding the view mid-slide.
449
+ const streamWindowRef = useRef<VisibleRange | null>(null);
450
+ // Whether that loop is the one driving the morph scalar, so settling it never
451
+ // cuts short a timeframe switch that happens to overlap.
452
+ const streamMorphRef = useRef(false);
329
453
  const [picture, setPicture] = useState<ChartFrame | null>(null);
330
454
 
331
455
  if (!handleRef.current && size.width > 0 && size.height > 0) {
@@ -341,33 +465,63 @@ export function useChartCore(
341
465
  easing: TransitionEasing | undefined;
342
466
  reduceMotion: boolean;
343
467
  interval: IntervalTransition;
344
- }>({ ms: 300, easing: undefined, reduceMotion: false, interval: 'transform' });
468
+ stream: StreamTransition;
469
+ streamMs: number;
470
+ }>({
471
+ ms: 300,
472
+ easing: undefined,
473
+ reduceMotion: false,
474
+ interval: 'transform',
475
+ stream: 'none',
476
+ streamMs: 150,
477
+ });
345
478
  animRef.current = {
346
479
  ms: Math.max(0, transition?.transitionMs ?? 300),
347
480
  easing: transition?.transitionEasing,
348
481
  reduceMotion: transition?.reduceMotion ?? false,
349
482
  interval: transition?.intervalTransition === 'fade' ? 'fade' : 'transform',
483
+ stream: transition?.streamTransition === 'transform' ? 'transform' : 'none',
484
+ // Shorter than transitionMs by default: ticks can land faster than a 300ms
485
+ // curve, and every one that does interrupts the last.
486
+ streamMs: Math.max(0, transition?.streamTransitionMs ?? 150),
350
487
  };
351
488
  const onFrameRef = useRef(transition?.onFrame);
352
489
  onFrameRef.current = transition?.onFrame;
353
490
  const seriesKey = transition?.seriesKey;
354
491
 
492
+ // Set only while the loading hand-off is in its *first* stage, which is the
493
+ // one an interruption can't simply land: stage one leaves `loading` set in
494
+ // the core, and only stage two releases it.
495
+ const loadingHandoffRef = useRef(false);
496
+
355
497
  // Stop an in-flight interval morph and land the core on the new candles.
356
498
  const endIntervalMorph = useCallback(() => {
357
499
  if (intervalMorphRaf.current != null) {
358
500
  cancelAnimationFrame(intervalMorphRaf.current);
359
501
  intervalMorphRaf.current = null;
360
502
  }
361
- handleRef.current?.setIntervalMorph(1);
503
+ const h = handleRef.current;
504
+ // Walk a half-finished hand-off through the rest of its stages rather than
505
+ // just stopping the clock, or the core would be left drawing the loading
506
+ // line over the data it was supposed to hand off to.
507
+ if (loadingHandoffRef.current) {
508
+ loadingHandoffRef.current = false;
509
+ h?.setLoadingMorph(1);
510
+ h?.beginLoadingReveal();
511
+ }
512
+ h?.setIntervalMorph(1);
362
513
  }, []);
363
514
 
364
515
  // Runs the interval morph clock. The core holds the pre-swap geometry (see
365
516
  // beginIntervalMorph) and reshapes each candle slot toward its new counterpart.
366
- const startIntervalMorph = useCallback((h: ChartHandle) => {
517
+ // `durationMs` overrides transitionMs for the loading hand-off, which splits
518
+ // it across two stages.
519
+ const startIntervalMorph = useCallback((h: ChartHandle, durationMs?: number) => {
367
520
  const { ms, easing } = animRef.current;
521
+ const dur = durationMs ?? ms;
368
522
  const start = performance.now();
369
523
  const step = (now: number) => {
370
- const p = Math.min(1, (now - start) / ms);
524
+ const p = Math.min(1, (now - start) / dur);
371
525
  h.setIntervalMorph(p < 1 ? ease(easing, p) : 1);
372
526
  const pic = h.render();
373
527
  if (pic) onFrameRef.current?.(pic);
@@ -376,12 +530,129 @@ export function useChartCore(
376
530
  intervalMorphRaf.current = requestAnimationFrame(step);
377
531
  }, []);
378
532
 
533
+ // Hands the loading line over to the data that just landed, in two stages:
534
+ // the line reshapes into the series' silhouette, then the candles grow out of
535
+ // it while it fades.
536
+ //
537
+ // Sequential rather than overlapped — the shape has to read as the data
538
+ // before the bars start emerging from it — so the two split transitionMs and
539
+ // the whole hand-off costs what any other transition costs.
540
+ const startLoadingHandoff = useCallback(
541
+ (h: ChartHandle) => {
542
+ const { ms, easing } = animRef.current;
543
+ const half = ms / 2;
544
+ loadingHandoffRef.current = true;
545
+ h.beginLoadingMorph();
546
+ const start = performance.now();
547
+ const step = (now: number) => {
548
+ const p = Math.min(1, (now - start) / half);
549
+ h.setLoadingMorph(p < 1 ? ease(easing, p) : 1);
550
+ const pic = h.render();
551
+ if (pic) onFrameRef.current?.(pic);
552
+ if (p < 1) {
553
+ intervalMorphRaf.current = requestAnimationFrame(step);
554
+ return;
555
+ }
556
+ intervalMorphRaf.current = null;
557
+ // Past here an interruption is an ordinary interval morph again: the
558
+ // core has left the loading state, so landing the clock is enough.
559
+ loadingHandoffRef.current = false;
560
+ // Stage two rides the interval-morph clock, which also carries the
561
+ // line's fade-out — so the bars' growth and the line's exit finish
562
+ // together instead of one outlasting the other.
563
+ h.beginLoadingReveal();
564
+ startIntervalMorph(h, half);
565
+ };
566
+ intervalMorphRaf.current = requestAnimationFrame(step);
567
+ },
568
+ [startIntervalMorph],
569
+ );
570
+
571
+ // Stops an in-flight stream animation and puts the chart somewhere coherent.
572
+ //
573
+ // A pending window shift always lands on its target: abandoned mid-slide it
574
+ // would strand the view between two bars, half a candle off the grid.
575
+ //
576
+ // `keepMorph` is for a tick restarting on top of one already running —
577
+ // beginStreamMorph blends out of the geometry currently on screen, so landing
578
+ // that geometry first would throw away the very thing it resumes from.
579
+ const settleStream = useCallback((keepMorph = false) => {
580
+ if (streamRaf.current != null) {
581
+ cancelAnimationFrame(streamRaf.current);
582
+ streamRaf.current = null;
583
+ }
584
+ const h = handleRef.current;
585
+ const target = streamWindowRef.current;
586
+ streamWindowRef.current = null;
587
+ if (target) h?.setVisibleRange(target.startMs, target.endMs);
588
+ if (streamMorphRef.current && !keepMorph) {
589
+ streamMorphRef.current = false;
590
+ h?.setIntervalMorph(1);
591
+ }
592
+ }, []);
593
+
594
+ // Runs the clock for a live update. One loop drives both halves so they land
595
+ // on the same frame.
596
+ //
597
+ // `window` is null for a plain tick; for an append it is where the view has to
598
+ // end up. The slide is measured from wherever the window is *now*, so a shift
599
+ // interrupting another continues from the current position instead of
600
+ // snapping back to the start of the last one.
601
+ const startStreamAnim = useCallback(
602
+ (h: ChartHandle, morphing: boolean, window: VisibleRange | null) => {
603
+ const { streamMs, easing } = animRef.current;
604
+ let from = window ? h.getVisibleRange() : null;
605
+ // What the previous frame left the window at. Anything else — a pan, a
606
+ // pinch — lands somewhere different, which is how the slide notices it is
607
+ // no longer the only thing moving the view and gets out of the way.
608
+ // Cheaper than teaching every gesture to cancel it, and it can't miss one.
609
+ let applied: VisibleRange | null = null;
610
+ streamWindowRef.current = window;
611
+ streamMorphRef.current = morphing;
612
+ const start = performance.now();
613
+ const step = (now: number) => {
614
+ if (from && applied) {
615
+ const now_w = h.getVisibleRange();
616
+ if (now_w.startMs !== applied.startMs || now_w.endMs !== applied.endMs) {
617
+ from = null;
618
+ streamWindowRef.current = null;
619
+ }
620
+ }
621
+ const p = Math.min(1, (now - start) / streamMs);
622
+ const e = p < 1 ? ease(easing, p) : 1;
623
+ if (morphing) h.setIntervalMorph(e);
624
+ if (from && window) {
625
+ applied = {
626
+ startMs: Math.round(from.startMs + (window.startMs - from.startMs) * e),
627
+ endMs: Math.round(from.endMs + (window.endMs - from.endMs) * e),
628
+ };
629
+ h.setVisibleRange(applied.startMs, applied.endMs);
630
+ }
631
+ const pic = h.render();
632
+ if (pic) onFrameRef.current?.(pic);
633
+ if (p < 1) {
634
+ streamRaf.current = requestAnimationFrame(step);
635
+ } else {
636
+ streamRaf.current = null;
637
+ streamWindowRef.current = null;
638
+ streamMorphRef.current = false;
639
+ }
640
+ };
641
+ streamRaf.current = requestAnimationFrame(step);
642
+ },
643
+ [],
644
+ );
645
+
379
646
  useEffect(() => {
380
647
  return () => {
381
648
  if (intervalMorphRaf.current != null) {
382
649
  cancelAnimationFrame(intervalMorphRaf.current);
383
650
  intervalMorphRaf.current = null;
384
651
  }
652
+ if (streamRaf.current != null) {
653
+ cancelAnimationFrame(streamRaf.current);
654
+ streamRaf.current = null;
655
+ }
385
656
  };
386
657
  }, []);
387
658
 
@@ -393,14 +664,28 @@ export function useChartCore(
393
664
  const startMs = visibleRange?.startMs ?? 0;
394
665
  const endMs = visibleRange?.endMs ?? 0;
395
666
 
667
+ // The core trusts `setLoading` outright, so the "and no data yet" half of the
668
+ // condition is decided here. Both halves matter: without `loading` a chart
669
+ // that legitimately has no bars would wave a placeholder forever, and without
670
+ // the emptiness check a background refresh of a loaded series would blank the
671
+ // chart the user is already reading.
672
+ const showLoadingLine = loading === true && candles.length === 0;
673
+ // Tracks whether the *core* is currently showing the line, which is what
674
+ // decides if the next data push is a hand-off. Distinct from `showLoadingLine`:
675
+ // that is this render's intent, this is what's on screen.
676
+ const lineUpRef = useRef(false);
677
+
396
678
  // Stable deps so inline `theme={{...}}` / `rsi={{...}}` literals don't re-run
397
679
  // the effect every render — only when the actual values change.
398
680
  const themeKey = theme ? JSON.stringify(theme) : '';
399
681
  const rsiKey = rsi ? JSON.stringify(rsi) : '';
400
682
  const macdKey = macd ? JSON.stringify(macd) : '';
683
+ const atrKey = atr ? JSON.stringify(atr) : '';
401
684
  const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
402
685
  const vwapKey = vwap ? JSON.stringify(vwap) : '';
403
686
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
687
+ const ichimokuKey = ichimoku ? JSON.stringify(ichimoku) : '';
688
+ const fvgKey = fairValueGaps ? JSON.stringify(fairValueGaps) : '';
404
689
  const volumeKey = volume ? JSON.stringify(volume) : '';
405
690
  const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
406
691
  const footprintsKey = footprints ? JSON.stringify(footprints) : '';
@@ -409,6 +694,10 @@ export function useChartCore(
409
694
  const h = handleRef.current;
410
695
  if (!h) return;
411
696
  h.setSize(size.width, size.height, size.pxRatio ?? 1);
697
+ // Ahead of setCandles, like setDefaultCandleWidth below: the default framing
698
+ // runs inside setCandles and reserves room past the newest candle for
699
+ // Ichimoku's leading spans, so it has to already know they're coming.
700
+ h.setIchimoku(ichimokuToSpec(ichimoku));
412
701
  // Drive the initial zoom from a target candle width. Pushed once, before the
413
702
  // first setCandles (while the core window is still 0/0), and only when the
414
703
  // caller isn't explicitly controlling the range.
@@ -425,6 +714,31 @@ export function useChartCore(
425
714
  // the viewport: a stream leaves it alone, a timeframe switch re-anchors and
426
715
  // morphs into it, a different asset resets it.
427
716
  let morphing = false;
717
+
718
+ if (showLoadingLine) {
719
+ // Clear the core's buffer, which the `candles.length > 0` gate below
720
+ // otherwise never does: pushing an empty array is treated as "hold the
721
+ // last frame" everywhere else, so a chart switching assets would still be
722
+ // holding the previous one's bars underneath the line — and would
723
+ // classify the incoming series as a timeframe switch rather than a fresh
724
+ // load. Scoped to the loading case so that hold-the-last-frame behavior
725
+ // is untouched for every other empty push.
726
+ if (!lineUpRef.current) {
727
+ endIntervalMorph();
728
+ settleStream();
729
+ h.setCandles(packCandles([]));
730
+ prevDataRef.current = null;
731
+ }
732
+ h.setLoading(true, !animRef.current.reduceMotion);
733
+ lineUpRef.current = true;
734
+ } else if (lineUpRef.current && candles.length === 0) {
735
+ // Loading resolved to nothing — an empty result, or an error the consumer
736
+ // handled. There's no geometry to morph into, so drop the line rather
737
+ // than leaving it waving at data that isn't coming.
738
+ h.setLoading(false, true);
739
+ lineUpRef.current = false;
740
+ }
741
+
428
742
  if (candles.length > 0) {
429
743
  const prev = prevDataRef.current;
430
744
  const freshHandle = prev == null || prev.handle !== h;
@@ -447,6 +761,60 @@ export function useChartCore(
447
761
  } | null = null;
448
762
  // The pre-swap candle envelope, used to scale-lock the y-axis below.
449
763
  let prevEnvelope: { low: number; high: number } | null = null;
764
+ // Set when this push is the loading line's hand-off to real data.
765
+ let handOff = false;
766
+ // Set for an animated live update: whether the last bar reshapes, and
767
+ // the window an appended bar should pull the view to.
768
+ let stream: { morph: boolean; window: VisibleRange | null } | null = null;
769
+ if (transitionKind === 'stream' && prev != null && !explicit) {
770
+ const { stream: mode, streamMs, reduceMotion } = animRef.current;
771
+ const stepMs = inferStepMs(candles);
772
+ if (
773
+ mode === 'transform' &&
774
+ streamMs > 0 &&
775
+ stepMs != null &&
776
+ !reduceMotion &&
777
+ onFrameRef.current != null
778
+ ) {
779
+ const lastMs = candles[candles.length - 1].timeMs;
780
+ const prevLastMs = prev.candles[prev.candles.length - 1].timeMs;
781
+ if (classifyStream(prev.candles, candles) === 'append') {
782
+ // Pull the window along by exactly what the data advanced, so the
783
+ // series translates a whole slot and the newest bar holds its
784
+ // place on screen. Only for a view still following the newest bar
785
+ // — someone reading history keeps their window.
786
+ //
787
+ // No capture here: slots pair from the right edge, so the new bar
788
+ // would take the previous one's geometry and drag every candle
789
+ // onto its neighbour. Translating the window moves them by their
790
+ // own timestamps instead.
791
+ const w = h.getVisibleRange();
792
+ const prevStepMs = inferStepMs(prev.candles) ?? stepMs;
793
+ if (isPinnedToLatest(w, prevLastMs, prevStepMs)) {
794
+ const by = lastMs - prevLastMs;
795
+ stream = {
796
+ morph: false,
797
+ window: { startMs: w.startMs + by, endMs: w.endMs + by },
798
+ };
799
+ }
800
+ } else {
801
+ stream = { morph: true, window: null };
802
+ }
803
+ }
804
+ if (stream?.morph) {
805
+ // Keep the geometry on screen for beginStreamMorph to resume from:
806
+ // at any real tick rate most ticks interrupt the previous one, and
807
+ // that continuity is what keeps the bar from stuttering.
808
+ settleStream(true);
809
+ h.beginStreamMorph();
810
+ } else {
811
+ // An append has no use for a capture — it would pair the new bar
812
+ // with the old one's geometry and drag the whole series along.
813
+ settleStream();
814
+ }
815
+ } else if (transitionKind === 'stream') {
816
+ settleStream();
817
+ }
450
818
  if (transitionKind === 'timeframe' && prev != null) {
451
819
  const oldWindow = h.getVisibleRange();
452
820
  const oldStepMs = inferStepMs(prev.candles);
@@ -475,6 +843,22 @@ export function useChartCore(
475
843
  endIntervalMorph();
476
844
  }
477
845
 
846
+ // The loading line's data has landed, so it hands over to the series
847
+ // instead of the chart cutting to it. Always classified 'initial' (the
848
+ // loading branch above cleared prevDataRef), so this runs after that
849
+ // branch's endIntervalMorph.
850
+ if (lineUpRef.current) {
851
+ lineUpRef.current = false;
852
+ handOff =
853
+ animRef.current.ms > 0 &&
854
+ !animRef.current.reduceMotion &&
855
+ onFrameRef.current != null;
856
+ // Snap path only. The hand-off itself starts after setCandles and the
857
+ // framing below: both its stages aim at where the candles will
858
+ // actually sit, so neither can be set up until they're there.
859
+ if (!handOff) h.setLoading(false, true);
860
+ }
861
+
478
862
  h.setCandles(packCandles(candles));
479
863
 
480
864
  if (transitionKind === 'timeframe') {
@@ -499,9 +883,16 @@ export function useChartCore(
499
883
  // Started after the new bounds are in place: the snapshot is in band
500
884
  // fractions, so frame 0 still matches the pre-switch pixels exactly.
501
885
  if (morphing) startIntervalMorph(h);
886
+ } else if (stream) {
887
+ // After setCandles, so the capture (and the window it slides from) is
888
+ // measured against the data the animation is heading toward.
889
+ startStreamAnim(h, stream.morph, stream.window);
502
890
  } else if (transitionKind === 'reset') {
503
891
  h.resetView();
504
892
  }
893
+ // After setCandles and the framing above, so the line aims at — and the
894
+ // candles grow from — the geometry each bar will actually occupy.
895
+ if (handOff) startLoadingHandoff(h);
505
896
  prevDataRef.current = { handle: h, candles, seriesKey };
506
897
  }
507
898
  }
@@ -522,9 +913,11 @@ export function useChartCore(
522
913
  }
523
914
  h.setRSI(rsiToSpec(rsi));
524
915
  h.setMACD(macdToSpec(macd));
916
+ h.setATR(atrToSpec(atr));
525
917
  h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
526
918
  h.setVWAP(vwapToSpec(vwap));
527
919
  h.setBollinger(bollingerToSpec(bollingerBands));
920
+ h.setFairValueGaps(fvgToSpec(fairValueGaps));
528
921
  h.setVolume(volumeToSpec(volume));
529
922
  // setVolume snaps the collapse scalar to its `enabled`, which would cut a
530
923
  // toggle animation short whenever this effect re-runs (a streaming candle, a
@@ -545,10 +938,11 @@ export function useChartCore(
545
938
  // frame 0 is pixel-identical to what's on screen, so there's nothing to show
546
939
  // in the meantime anyway.
547
940
  if (!morphing) setPicture(h.render());
548
- // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines/
549
- // footprints are represented by their *Key deps.
941
+ // theme/rsi/macd/atr/movingAverages/vwap/bollingerBands/ichimoku/
942
+ // fairValueGaps/volume/priceLines/footprints are represented by their *Key
943
+ // deps.
550
944
  // eslint-disable-next-line react-hooks/exhaustive-deps
551
- }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, endIntervalMorph]);
945
+ }, [candles, showLoadingLine, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, atrKey, maKey, vwapKey, bollingerKey, ichimokuKey, fvgKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, startLoadingHandoff, endIntervalMorph, startStreamAnim, settleStream]);
552
946
 
553
947
  return { handle: handleRef.current, picture, volumeCollapseRef };
554
948
  }