react-native-vroom-chart 0.15.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 (42) hide show
  1. package/cpp/VroomChartHostObject.cpp +171 -1
  2. package/cpp/_core_include/vroom/vroom_chart.h +137 -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 +461 -172
  8. package/cpp/_core_src/chart.h +150 -1
  9. package/cpp/_core_src/chart_facade.cpp +209 -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/fvg_overlay.cpp +262 -0
  13. package/cpp/_core_src/fvg_overlay.h +43 -0
  14. package/cpp/_core_src/ichimoku.cpp +65 -0
  15. package/cpp/_core_src/ichimoku.h +45 -0
  16. package/cpp/_core_src/labels.cpp +3 -0
  17. package/cpp/_core_src/line_morph.h +159 -0
  18. package/cpp/_core_src/ma_overlay.cpp +259 -36
  19. package/cpp/_core_src/ma_overlay.h +57 -2
  20. package/cpp/_core_src/macd.cpp +24 -1
  21. package/cpp/_core_src/macd.h +16 -0
  22. package/cpp/_core_src/macd_pane.cpp +71 -62
  23. package/cpp/_core_src/macd_pane.h +13 -1
  24. package/cpp/_core_src/pane_series.h +169 -0
  25. package/cpp/_core_src/rsi.cpp +4 -0
  26. package/cpp/_core_src/rsi.h +7 -0
  27. package/cpp/_core_src/rsi_pane.cpp +85 -29
  28. package/cpp/_core_src/rsi_pane.h +11 -1
  29. package/cpp/_core_src/viewport.h +35 -0
  30. package/lib/index.d.mts +251 -3
  31. package/lib/index.d.ts +251 -3
  32. package/lib/index.js +224 -6
  33. package/lib/index.js.map +1 -1
  34. package/lib/index.mjs +224 -6
  35. package/lib/index.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/src/VroomChart.tsx +21 -3
  38. package/src/dataTransitions.ts +47 -0
  39. package/src/index.ts +5 -0
  40. package/src/jsi.d.ts +97 -1
  41. package/src/types.ts +5 -0
  42. package/src/useChartCore.ts +284 -5
@@ -126,6 +126,53 @@ export function classifyTransition(
126
126
  return closeRatio <= MAX_SAME_ASSET_CLOSE_RATIO && endsTogether ? 'timeframe' : 'reset';
127
127
  }
128
128
 
129
+ /**
130
+ * What a `'stream'` update did to the series: `'tick'` revised the bar already
131
+ * on screen, `'append'` brought at least one new one.
132
+ *
133
+ * The two animate by different means. A tick keeps the bar count, so the morph
134
+ * capture's slots still pair one-to-one and the last bar can reshape in place.
135
+ * An append can't use that capture at all — slots pair from the right edge, so
136
+ * a new bar shifts every candle onto its neighbour's geometry — and instead
137
+ * advances the visible window, which translates the series left and lets the
138
+ * new bar in at the right edge.
139
+ */
140
+ export type StreamKind = 'tick' | 'append';
141
+
142
+ /**
143
+ * Which of the two a `'stream'` transition is. Read from the newest timestamp
144
+ * rather than a length comparison, so a rolling buffer that drops a bar from
145
+ * the front as it adds one to the back still reads as an append.
146
+ *
147
+ * An update that both appends and revises the bar that just closed counts as an
148
+ * append: the translation is the dominant motion, and the revision is a final
149
+ * print that has nowhere to slot-pair to.
150
+ */
151
+ export function classifyStream(prev: Candle[], next: Candle[]): StreamKind {
152
+ if (prev.length === 0 || next.length === 0) return 'tick';
153
+ return next[next.length - 1].timeMs > prev[prev.length - 1].timeMs
154
+ ? 'append'
155
+ : 'tick';
156
+ }
157
+
158
+ /**
159
+ * Whether the view is still following the newest bar, which is what decides if
160
+ * an appended bar should pull the window along with it.
161
+ *
162
+ * True when the right edge sits at or past the newest bar's slot *end* — where
163
+ * the default framing leaves it, plus whatever gap it reserved. Someone who has
164
+ * panned back into history falls below that and is left where they are: nothing
165
+ * is more disorienting than the chart walking out from under you while you read
166
+ * it.
167
+ */
168
+ export function isPinnedToLatest(
169
+ window: VisibleRange,
170
+ lastMs: number,
171
+ stepMs: number,
172
+ ): boolean {
173
+ return window.endMs >= lastMs + stepMs;
174
+ }
175
+
129
176
  /**
130
177
  * The visible window to apply after a timeframe switch so each candle keeps
131
178
  * the exact pixel width it had before: the visible slot count is preserved and
package/src/index.ts CHANGED
@@ -14,15 +14,20 @@ export type {
14
14
  VisibleRange,
15
15
  RSIConfig,
16
16
  MACDConfig,
17
+ ATRConfig,
18
+ ATRSmoothing,
17
19
  MASource,
18
20
  MAKind,
19
21
  MovingAverageOverlay,
20
22
  VWAPConfig,
21
23
  BollingerBandsConfig,
24
+ IchimokuConfig,
25
+ FairValueGapsConfig,
22
26
  VolumeConfig,
23
27
  ChartType,
24
28
  TransitionEasing,
25
29
  IntervalTransition,
30
+ StreamTransition,
26
31
  PriceLine,
27
32
  PriceLinesStyle,
28
33
  Footprint,
package/src/jsi.d.ts CHANGED
@@ -73,7 +73,20 @@ export interface ChartHandle {
73
73
  */
74
74
  beginIntervalMorph(mode?: IntervalTransition): void;
75
75
  /**
76
- * Advance the interval morph started by beginIntervalMorph. `t` (clamped to
76
+ * Capture the visible geometry so the next setCandles can ease a live tick
77
+ * into place. Always a transform, and unlike beginIntervalMorph it leaves the
78
+ * axes alone — the interval hasn't changed, so its ticks must not fade.
79
+ * Restarting one still in flight continues from the shape on screen, so ticks
80
+ * arriving faster than the animation lands stay smooth. Call before
81
+ * setCandles, then drive setIntervalMorph from 0 to 1.
82
+ *
83
+ * Not for an update that appends a bar: slots pair from the right edge, so a
84
+ * new bar would shift every candle onto its neighbour's geometry. Advance the
85
+ * visible range instead and let the series translate.
86
+ */
87
+ beginStreamMorph(): void;
88
+ /**
89
+ * Advance the morph started by either begin method above. `t` (clamped to
77
90
  * 0..1) is the eased progress: 0 renders the captured geometry pixel-
78
91
  * identically to the pre-swap frame, 1 renders the new candles and releases
79
92
  * the capture. Driven per-frame by the host animation loop.
@@ -173,6 +186,7 @@ export interface ChartHandle {
173
186
  maWidth: number;
174
187
  bandColor: number;
175
188
  bandsVisible: boolean;
189
+ extremeFill: boolean;
176
190
  }): void;
177
191
  /**
178
192
  * Configures the MACD pane. source/maKind mirror setOverlays' encodings;
@@ -201,6 +215,18 @@ export interface ChartHandle {
201
215
  zeroColor: number;
202
216
  zeroVisible: boolean;
203
217
  }): void;
218
+ /**
219
+ * Configures the ATR pane. smoothing: 0=RMA (Wilder), 1=SMA, 2=EMA;
220
+ * lineColor is packed 0xAARRGGBB where 0 means inherit, and a non-positive
221
+ * width inherits the default stroke.
222
+ */
223
+ setATR(spec: {
224
+ enabled: boolean;
225
+ period: number;
226
+ smoothing: number;
227
+ lineColor: number;
228
+ lineWidth: number;
229
+ }): void;
204
230
  /**
205
231
  * Replaces the full set of MA/EMA overlay lines drawn on the price pane.
206
232
  * kind: 0=SMA, 1=EMA; source: 0=close,1=open,2=high,3=low,4=hl2,5=hlc3,6=ohlc4;
@@ -245,6 +271,76 @@ export interface ChartHandle {
245
271
  fillEnabled: boolean;
246
272
  fillOpacity: number;
247
273
  }): void;
274
+ /**
275
+ * Configures the Ichimoku overlay (five price-pane lines + the cloud between
276
+ * the leading spans). Colors are packed 0xAARRGGBB; cloudOpacity is 0..1.
277
+ *
278
+ * `displacement` is in candle slots and applies at draw time: the leading
279
+ * spans plot that many slots ahead of the bar they came from, past the newest
280
+ * candle, and chikou that many behind. Enabling the overlay also pulls the
281
+ * view forward far enough to show them.
282
+ */
283
+ setIchimoku(spec: {
284
+ enabled: boolean;
285
+ tenkanPeriod: number;
286
+ kijunPeriod: number;
287
+ senkouBPeriod: number;
288
+ displacement: number;
289
+ tenkanColor: number;
290
+ tenkanWidth: number;
291
+ tenkanEnabled: boolean;
292
+ kijunColor: number;
293
+ kijunWidth: number;
294
+ kijunEnabled: boolean;
295
+ senkouAColor: number;
296
+ senkouAWidth: number;
297
+ senkouAEnabled: boolean;
298
+ senkouBColor: number;
299
+ senkouBWidth: number;
300
+ senkouBEnabled: boolean;
301
+ chikouColor: number;
302
+ chikouWidth: number;
303
+ chikouEnabled: boolean;
304
+ cloudEnabled: boolean;
305
+ bullishCloudColor: number;
306
+ bearishCloudColor: number;
307
+ cloudOpacity: number;
308
+ }): void;
309
+ /**
310
+ * Configures the Fair Value Gap overlay (shaded imbalance boxes on the price
311
+ * pane). Colors are packed 0xAARRGGBB; opacity is 0..1.
312
+ *
313
+ * `maxBarsBack`, `boxLength` and `labelDistance` are all counted in candle
314
+ * slots. `fillType` is 0 for a close past the far edge or 1 for a wick
315
+ * reaching it, and `borderStyle` is 0 solid / 1 dotted / 2 dashed. Only
316
+ * enabled, maxBarsBack, waitForClose and fillType rescan for gaps.
317
+ */
318
+ setFairValueGaps(spec: {
319
+ enabled: boolean;
320
+ maxBarsBack: number;
321
+ waitForClose: boolean;
322
+ fillType: number;
323
+ deleteAfterFill: boolean;
324
+ extendBoxes: boolean;
325
+ boxLength: number;
326
+ bullishColor: number;
327
+ bearishColor: number;
328
+ opacity: number;
329
+ borderEnabled: boolean;
330
+ borderStyle: number;
331
+ borderWidth: number;
332
+ bullishBorderColor: number;
333
+ bearishBorderColor: number;
334
+ labelsEnabled: boolean;
335
+ label: string;
336
+ labelDistance: number;
337
+ labelColor: number;
338
+ labelFontSize: number;
339
+ showInverse: boolean;
340
+ inverseBullishColor: number;
341
+ inverseBearishColor: number;
342
+ inverseLabel: string;
343
+ }): void;
248
344
  /**
249
345
  * Configures the volume bars under the candles. `heightFrac` is the tallest
250
346
  * bar as a fraction of the price pane. The style fields carry an inherit
package/src/types.ts CHANGED
@@ -16,11 +16,16 @@ export type {
16
16
  MovingAverageOverlay,
17
17
  VWAPConfig,
18
18
  BollingerBandsConfig,
19
+ IchimokuConfig,
20
+ FairValueGapsConfig,
19
21
  VolumeConfig,
20
22
  MACDConfig,
23
+ ATRConfig,
24
+ ATRSmoothing,
21
25
  ChartType,
22
26
  TransitionEasing,
23
27
  IntervalTransition,
28
+ StreamTransition,
24
29
  PriceLine,
25
30
  PriceLinesStyle,
26
31
  Footprint,
@@ -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,9 +415,12 @@ 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,
@@ -326,6 +440,13 @@ export function useChartCore(
326
440
  seriesKey?: string;
327
441
  } | null>(null);
328
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);
329
450
  const [picture, setPicture] = useState<ChartFrame | null>(null);
330
451
 
331
452
  if (!handleRef.current && size.width > 0 && size.height > 0) {
@@ -341,12 +462,25 @@ export function useChartCore(
341
462
  easing: TransitionEasing | undefined;
342
463
  reduceMotion: boolean;
343
464
  interval: IntervalTransition;
344
- }>({ 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
+ });
345
475
  animRef.current = {
346
476
  ms: Math.max(0, transition?.transitionMs ?? 300),
347
477
  easing: transition?.transitionEasing,
348
478
  reduceMotion: transition?.reduceMotion ?? false,
349
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),
350
484
  };
351
485
  const onFrameRef = useRef(transition?.onFrame);
352
486
  onFrameRef.current = transition?.onFrame;
@@ -376,12 +510,91 @@ export function useChartCore(
376
510
  intervalMorphRaf.current = requestAnimationFrame(step);
377
511
  }, []);
378
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
+
379
588
  useEffect(() => {
380
589
  return () => {
381
590
  if (intervalMorphRaf.current != null) {
382
591
  cancelAnimationFrame(intervalMorphRaf.current);
383
592
  intervalMorphRaf.current = null;
384
593
  }
594
+ if (streamRaf.current != null) {
595
+ cancelAnimationFrame(streamRaf.current);
596
+ streamRaf.current = null;
597
+ }
385
598
  };
386
599
  }, []);
387
600
 
@@ -398,9 +611,12 @@ export function useChartCore(
398
611
  const themeKey = theme ? JSON.stringify(theme) : '';
399
612
  const rsiKey = rsi ? JSON.stringify(rsi) : '';
400
613
  const macdKey = macd ? JSON.stringify(macd) : '';
614
+ const atrKey = atr ? JSON.stringify(atr) : '';
401
615
  const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
402
616
  const vwapKey = vwap ? JSON.stringify(vwap) : '';
403
617
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
618
+ const ichimokuKey = ichimoku ? JSON.stringify(ichimoku) : '';
619
+ const fvgKey = fairValueGaps ? JSON.stringify(fairValueGaps) : '';
404
620
  const volumeKey = volume ? JSON.stringify(volume) : '';
405
621
  const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
406
622
  const footprintsKey = footprints ? JSON.stringify(footprints) : '';
@@ -409,6 +625,10 @@ export function useChartCore(
409
625
  const h = handleRef.current;
410
626
  if (!h) return;
411
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));
412
632
  // Drive the initial zoom from a target candle width. Pushed once, before the
413
633
  // first setCandles (while the core window is still 0/0), and only when the
414
634
  // caller isn't explicitly controlling the range.
@@ -447,6 +667,58 @@ export function useChartCore(
447
667
  } | null = null;
448
668
  // The pre-swap candle envelope, used to scale-lock the y-axis below.
449
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
+ }
450
722
  if (transitionKind === 'timeframe' && prev != null) {
451
723
  const oldWindow = h.getVisibleRange();
452
724
  const oldStepMs = inferStepMs(prev.candles);
@@ -499,6 +771,10 @@ export function useChartCore(
499
771
  // Started after the new bounds are in place: the snapshot is in band
500
772
  // fractions, so frame 0 still matches the pre-switch pixels exactly.
501
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);
502
778
  } else if (transitionKind === 'reset') {
503
779
  h.resetView();
504
780
  }
@@ -522,9 +798,11 @@ export function useChartCore(
522
798
  }
523
799
  h.setRSI(rsiToSpec(rsi));
524
800
  h.setMACD(macdToSpec(macd));
801
+ h.setATR(atrToSpec(atr));
525
802
  h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
526
803
  h.setVWAP(vwapToSpec(vwap));
527
804
  h.setBollinger(bollingerToSpec(bollingerBands));
805
+ h.setFairValueGaps(fvgToSpec(fairValueGaps));
528
806
  h.setVolume(volumeToSpec(volume));
529
807
  // setVolume snaps the collapse scalar to its `enabled`, which would cut a
530
808
  // toggle animation short whenever this effect re-runs (a streaming candle, a
@@ -545,10 +823,11 @@ export function useChartCore(
545
823
  // frame 0 is pixel-identical to what's on screen, so there's nothing to show
546
824
  // in the meantime anyway.
547
825
  if (!morphing) setPicture(h.render());
548
- // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines/
549
- // footprints are 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.
550
829
  // 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]);
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]);
552
831
 
553
832
  return { handle: handleRef.current, picture, volumeCollapseRef };
554
833
  }