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.
- package/cpp/VroomChartHostObject.cpp +171 -1
- package/cpp/_core_include/vroom/vroom_chart.h +137 -4
- package/cpp/_core_src/atr.cpp +67 -0
- package/cpp/_core_src/atr.h +44 -0
- package/cpp/_core_src/atr_pane.cpp +162 -0
- package/cpp/_core_src/atr_pane.h +48 -0
- package/cpp/_core_src/chart.cpp +461 -172
- package/cpp/_core_src/chart.h +150 -1
- package/cpp/_core_src/chart_facade.cpp +209 -23
- package/cpp/_core_src/fair_value_gaps.cpp +76 -0
- package/cpp/_core_src/fair_value_gaps.h +54 -0
- package/cpp/_core_src/fvg_overlay.cpp +262 -0
- package/cpp/_core_src/fvg_overlay.h +43 -0
- package/cpp/_core_src/ichimoku.cpp +65 -0
- package/cpp/_core_src/ichimoku.h +45 -0
- package/cpp/_core_src/labels.cpp +3 -0
- package/cpp/_core_src/line_morph.h +159 -0
- package/cpp/_core_src/ma_overlay.cpp +259 -36
- package/cpp/_core_src/ma_overlay.h +57 -2
- package/cpp/_core_src/macd.cpp +24 -1
- package/cpp/_core_src/macd.h +16 -0
- package/cpp/_core_src/macd_pane.cpp +71 -62
- package/cpp/_core_src/macd_pane.h +13 -1
- package/cpp/_core_src/pane_series.h +169 -0
- package/cpp/_core_src/rsi.cpp +4 -0
- package/cpp/_core_src/rsi.h +7 -0
- package/cpp/_core_src/rsi_pane.cpp +85 -29
- package/cpp/_core_src/rsi_pane.h +11 -1
- package/cpp/_core_src/viewport.h +35 -0
- package/lib/index.d.mts +251 -3
- package/lib/index.d.ts +251 -3
- package/lib/index.js +224 -6
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +224 -6
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/VroomChart.tsx +21 -3
- package/src/dataTransitions.ts +47 -0
- package/src/index.ts +5 -0
- package/src/jsi.d.ts +97 -1
- package/src/types.ts +5 -0
- package/src/useChartCore.ts +284 -5
package/src/dataTransitions.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
package/src/useChartCore.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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/
|
|
549
|
-
// footprints are represented by their *Key
|
|
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
|
}
|