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