react-native-vroom-chart 0.7.0 → 0.8.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/lib/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/VroomChart.tsx
2
- import React, { useEffect as useEffect2, useRef as useRef2, useCallback, useState as useState2, useMemo } from "react";
2
+ import React, { useEffect as useEffect2, useRef as useRef2, useCallback as useCallback2, useState as useState2, useMemo } from "react";
3
3
  import { View } from "react-native";
4
4
  import { Canvas, Picture, Skia } from "@shopify/react-native-skia";
5
5
  import {
@@ -7,15 +7,95 @@ import {
7
7
  GestureDetector,
8
8
  GestureHandlerRootView
9
9
  } from "react-native-gesture-handler";
10
- import { useSharedValue } from "react-native-reanimated";
10
+ import { useReducedMotion, useSharedValue } from "react-native-reanimated";
11
11
 
12
12
  // src/useChartCore.ts
13
- import { useEffect, useRef, useState } from "react";
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
14
 
15
15
  // src/NativeVroomChart.ts
16
16
  import { TurboModuleRegistry } from "react-native";
17
17
  var NativeVroomChart_default = TurboModuleRegistry.getEnforcing("VroomChartModule");
18
18
 
19
+ // src/dataTransitions.ts
20
+ var STEP_TOLERANCE = 0.01;
21
+ var MAX_SAME_ASSET_CLOSE_RATIO = 1.25;
22
+ var MAX_END_DRIFT_STEPS = 3;
23
+ var MAX_STREAM_ADVANCE_STEPS = 5;
24
+ function inferStepMs(candles) {
25
+ if (candles.length < 2) return null;
26
+ const k = Math.min(candles.length - 1, 8);
27
+ const diffs = [];
28
+ for (let i = 0; i < k; i++) diffs.push(candles[i + 1].timeMs - candles[i].timeMs);
29
+ diffs.sort((a, b) => a - b);
30
+ const median = diffs[Math.floor(diffs.length / 2)];
31
+ return median > 0 ? median : null;
32
+ }
33
+ function indexByTime(candles, t) {
34
+ let lo = 0;
35
+ let hi = candles.length - 1;
36
+ while (lo <= hi) {
37
+ const mid = lo + hi >>> 1;
38
+ const v = candles[mid].timeMs;
39
+ if (v === t) return mid;
40
+ if (v < t) lo = mid + 1;
41
+ else hi = mid - 1;
42
+ }
43
+ return -1;
44
+ }
45
+ function classifyTransition(prev, next, seriesKeyChanged) {
46
+ if (!prev || prev.length === 0) return "initial";
47
+ if (next.length === 0) return "stream";
48
+ if (seriesKeyChanged) return "reset";
49
+ const prevStep = inferStepMs(prev);
50
+ const nextStep = inferStepMs(next);
51
+ if (prevStep == null || nextStep == null) return "reset";
52
+ const prevLast = prev[prev.length - 1];
53
+ const nextLast = next[next.length - 1];
54
+ if (Math.abs(nextStep - prevStep) <= prevStep * STEP_TOLERANCE) {
55
+ const idx = indexByTime(next, prevLast.timeMs);
56
+ const aligned = idx >= 0;
57
+ const sharedBarRatio = aligned && next[idx].close > 0 && prevLast.close > 0 ? Math.max(next[idx].close / prevLast.close, prevLast.close / next[idx].close) : Infinity;
58
+ const advanced = nextLast.timeMs >= prevLast.timeMs && nextLast.timeMs - prevLast.timeMs <= MAX_STREAM_ADVANCE_STEPS * nextStep;
59
+ return sharedBarRatio <= MAX_SAME_ASSET_CLOSE_RATIO && advanced ? "stream" : "reset";
60
+ }
61
+ const closeRatio = prevLast.close > 0 && nextLast.close > 0 ? Math.max(nextLast.close / prevLast.close, prevLast.close / nextLast.close) : Infinity;
62
+ const prevEnd = prevLast.timeMs + prevStep;
63
+ const nextEnd = nextLast.timeMs + nextStep;
64
+ const endsTogether = Math.abs(nextEnd - prevEnd) <= MAX_END_DRIFT_STEPS * Math.max(prevStep, nextStep);
65
+ return closeRatio <= MAX_SAME_ASSET_CLOSE_RATIO && endsTogether ? "timeframe" : "reset";
66
+ }
67
+ function timeframeWindow(oldWindow, oldStepMs, oldLastMs, newStepMs, newLastMs) {
68
+ const slots = (oldWindow.endMs - oldWindow.startMs) / oldStepMs;
69
+ const offsetRaw = (oldWindow.endMs - oldLastMs) / oldStepMs;
70
+ const offsetSlots = Math.min(Math.max(offsetRaw, 0), slots * 0.75);
71
+ const endMs = Math.round(newLastMs + offsetSlots * newStepMs);
72
+ return { startMs: Math.round(endMs - slots * newStepMs), endMs };
73
+ }
74
+
75
+ // src/easing.ts
76
+ function ease(kind, p) {
77
+ switch (kind) {
78
+ case "linear":
79
+ return p;
80
+ case "ease-in":
81
+ return p * p;
82
+ case "ease-out":
83
+ return p * (2 - p);
84
+ default:
85
+ return p * p * (3 - 2 * p);
86
+ }
87
+ }
88
+ var EASINGS = [
89
+ "linear",
90
+ "ease-in",
91
+ "ease-out",
92
+ "ease-in-out"
93
+ ];
94
+ function easingIndex(kind) {
95
+ const i = kind ? EASINGS.indexOf(kind) : -1;
96
+ return i < 0 ? EASINGS.indexOf("ease-in-out") : i;
97
+ }
98
+
19
99
  // src/packCandles.ts
20
100
  var BYTES_PER_CANDLE = 48;
21
101
  function packCandles(candles) {
@@ -250,15 +330,53 @@ function ensureInstalled() {
250
330
  }
251
331
  installed = true;
252
332
  }
253
- function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType, theme, rsi, macd, movingAverages, vwap, bollingerBands, volume, priceLines) {
333
+ function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType, theme, rsi, macd, movingAverages, vwap, bollingerBands, volume, priceLines, transition) {
254
334
  const handleRef = useRef(null);
255
335
  const defaultWidthAppliedRef = useRef(false);
256
336
  const volumeCollapseRef = useRef(null);
337
+ const prevDataRef = useRef(null);
338
+ const intervalMorphRaf = useRef(null);
257
339
  const [picture, setPicture] = useState(null);
258
340
  if (!handleRef.current && size.width > 0 && size.height > 0) {
259
341
  ensureInstalled();
260
342
  handleRef.current = globalThis.VroomChartJSI.create();
261
343
  }
344
+ const animRef = useRef({ ms: 300, easing: void 0, reduceMotion: false });
345
+ animRef.current = {
346
+ ms: Math.max(0, transition?.transitionMs ?? 300),
347
+ easing: transition?.transitionEasing,
348
+ reduceMotion: transition?.reduceMotion ?? false
349
+ };
350
+ const onFrameRef = useRef(transition?.onFrame);
351
+ onFrameRef.current = transition?.onFrame;
352
+ const seriesKey = transition?.seriesKey;
353
+ const endIntervalMorph = useCallback(() => {
354
+ if (intervalMorphRaf.current != null) {
355
+ cancelAnimationFrame(intervalMorphRaf.current);
356
+ intervalMorphRaf.current = null;
357
+ }
358
+ handleRef.current?.setIntervalMorph(1);
359
+ }, []);
360
+ const startIntervalMorph = useCallback((h) => {
361
+ const { ms, easing } = animRef.current;
362
+ const start = performance.now();
363
+ const step = (now) => {
364
+ const p = Math.min(1, (now - start) / ms);
365
+ h.setIntervalMorph(p < 1 ? ease(easing, p) : 1);
366
+ const pic = h.render();
367
+ if (pic) onFrameRef.current?.(pic);
368
+ intervalMorphRaf.current = p < 1 ? requestAnimationFrame(step) : null;
369
+ };
370
+ intervalMorphRaf.current = requestAnimationFrame(step);
371
+ }, []);
372
+ useEffect(() => {
373
+ return () => {
374
+ if (intervalMorphRaf.current != null) {
375
+ cancelAnimationFrame(intervalMorphRaf.current);
376
+ intervalMorphRaf.current = null;
377
+ }
378
+ };
379
+ }, []);
262
380
  const explicit = visibleRange != null;
263
381
  const startMs = visibleRange?.startMs ?? 0;
264
382
  const endMs = visibleRange?.endMs ?? 0;
@@ -278,8 +396,54 @@ function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType
278
396
  h.setDefaultCandleWidth(defaultCandleWidth);
279
397
  defaultWidthAppliedRef.current = true;
280
398
  }
399
+ let morphing = false;
281
400
  if (candles.length > 0) {
282
- h.setCandles(packCandles(candles));
401
+ const prev = prevDataRef.current;
402
+ const freshHandle = prev == null || prev.handle !== h;
403
+ if (freshHandle || prev.candles !== candles || prev.seriesKey !== seriesKey) {
404
+ const transitionKind = freshHandle ? "initial" : explicit ? "stream" : classifyTransition(prev.candles, candles, seriesKey !== prev.seriesKey);
405
+ let tfArgs = null;
406
+ let prevEnvelope = null;
407
+ if (transitionKind === "timeframe" && prev != null) {
408
+ const oldWindow = h.getVisibleRange();
409
+ const oldStepMs = inferStepMs(prev.candles);
410
+ if (oldWindow.endMs > oldWindow.startMs && oldStepMs != null) {
411
+ tfArgs = {
412
+ oldWindow,
413
+ oldStepMs,
414
+ oldLastMs: prev.candles[prev.candles.length - 1].timeMs
415
+ };
416
+ }
417
+ prevEnvelope = h.getVisiblePriceEnvelope();
418
+ morphing = animRef.current.ms > 0 && !animRef.current.reduceMotion && onFrameRef.current != null;
419
+ if (morphing) {
420
+ endIntervalMorph();
421
+ h.beginIntervalMorph();
422
+ }
423
+ } else if (transitionKind === "initial" || transitionKind === "reset") {
424
+ endIntervalMorph();
425
+ }
426
+ h.setCandles(packCandles(candles));
427
+ if (transitionKind === "timeframe") {
428
+ const newStepMs = inferStepMs(candles);
429
+ if (tfArgs && newStepMs != null) {
430
+ const w = timeframeWindow(
431
+ tfArgs.oldWindow,
432
+ tfArgs.oldStepMs,
433
+ tfArgs.oldLastMs,
434
+ newStepMs,
435
+ candles[candles.length - 1].timeMs
436
+ );
437
+ h.setVisibleRange(w.startMs, w.endMs);
438
+ }
439
+ if (prevEnvelope) h.preservePriceEnvelope(prevEnvelope.low, prevEnvelope.high);
440
+ else h.resetPriceScale();
441
+ if (morphing) startIntervalMorph(h);
442
+ } else if (transitionKind === "reset") {
443
+ h.resetView();
444
+ }
445
+ prevDataRef.current = { handle: h, candles, seriesKey };
446
+ }
283
447
  }
284
448
  if (explicit) {
285
449
  h.setVisibleRange(startMs, endMs);
@@ -298,39 +462,16 @@ function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType
298
462
  h.setPriceLines(
299
463
  priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES
300
464
  );
301
- setPicture(h.render());
302
- }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey]);
465
+ if (!morphing) setPicture(h.render());
466
+ }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
303
467
  return { handle: handleRef.current, picture, volumeCollapseRef };
304
468
  }
305
469
 
306
- // src/easing.ts
307
- function ease(kind, p) {
308
- switch (kind) {
309
- case "linear":
310
- return p;
311
- case "ease-in":
312
- return p * p;
313
- case "ease-out":
314
- return p * (2 - p);
315
- default:
316
- return p * p * (3 - 2 * p);
317
- }
318
- }
319
- var EASINGS = [
320
- "linear",
321
- "ease-in",
322
- "ease-out",
323
- "ease-in-out"
324
- ];
325
- function easingIndex(kind) {
326
- const i = kind ? EASINGS.indexOf(kind) : -1;
327
- return i < 0 ? EASINGS.indexOf("ease-in-out") : i;
328
- }
329
-
330
470
  // src/VroomChart.tsx
331
471
  function VroomChart(props) {
332
472
  const {
333
473
  candles,
474
+ seriesKey,
334
475
  width: widthProp,
335
476
  height: heightProp,
336
477
  style,
@@ -358,7 +499,7 @@ function VroomChart(props) {
358
499
  const [measured, setMeasured] = useState2({ width: 0, height: 0 });
359
500
  const width = widthProp ?? measured.width;
360
501
  const height = heightProp ?? measured.height;
361
- const onLayout = useCallback((e) => {
502
+ const onLayout = useCallback2((e) => {
362
503
  const w = Math.round(e.nativeEvent.layout.width);
363
504
  const h = Math.round(e.nativeEvent.layout.height);
364
505
  setMeasured(
@@ -373,6 +514,19 @@ function VroomChart(props) {
373
514
  } : void 0,
374
515
  [priceLines, priceLinesStyle, onPriceLineClose]
375
516
  );
517
+ const emptyPicture = useMemo(() => {
518
+ const rec = Skia.PictureRecorder();
519
+ rec.beginRecording(Skia.XYWHRect(0, 0, 1, 1));
520
+ return rec.finishRecordingAsPicture();
521
+ }, []);
522
+ const pictureSV = useSharedValue(emptyPicture);
523
+ const reduceMotion = useReducedMotion();
524
+ const onFrame = useCallback2(
525
+ (p) => {
526
+ pictureSV.value = p;
527
+ },
528
+ [pictureSV]
529
+ );
376
530
  const { handle, picture, volumeCollapseRef } = useChartCore(
377
531
  candles,
378
532
  { width, height },
@@ -386,21 +540,16 @@ function VroomChart(props) {
386
540
  vwap,
387
541
  bollingerBands,
388
542
  volume,
389
- priceLinesProp
543
+ priceLinesProp,
544
+ { seriesKey, transitionMs, transitionEasing, reduceMotion, onFrame }
390
545
  );
391
- const emptyPicture = useMemo(() => {
392
- const rec = Skia.PictureRecorder();
393
- rec.beginRecording(Skia.XYWHRect(0, 0, 1, 1));
394
- return rec.finishRecordingAsPicture();
395
- }, []);
396
- const pictureSV = useSharedValue(emptyPicture);
397
546
  const crosshairActive = useRef2(false);
398
547
  const lastCrosshairTime = useRef2(null);
399
548
  useEffect2(() => {
400
549
  if (picture) pictureSV.value = picture;
401
550
  }, [picture, pictureSV]);
402
551
  const decayRaf = useRef2(null);
403
- const cancelDecay = useCallback(() => {
552
+ const cancelDecay = useCallback2(() => {
404
553
  if (decayRaf.current != null) {
405
554
  cancelAnimationFrame(decayRaf.current);
406
555
  decayRaf.current = null;
@@ -408,7 +557,7 @@ function VroomChart(props) {
408
557
  }, []);
409
558
  useEffect2(() => cancelDecay, [cancelDecay]);
410
559
  const animRaf = useRef2(null);
411
- const animTick = useCallback(() => {
560
+ const animTick = useCallback2(() => {
412
561
  animRaf.current = null;
413
562
  if (!handle) return;
414
563
  const next = handle.render();
@@ -417,7 +566,7 @@ function VroomChart(props) {
417
566
  animRaf.current = requestAnimationFrame(animTick);
418
567
  }
419
568
  }, [handle, pictureSV]);
420
- const maybeStartAnim = useCallback(() => {
569
+ const maybeStartAnim = useCallback2(() => {
421
570
  if (animRaf.current != null) return;
422
571
  if (!handle?.isAnimating()) return;
423
572
  animRaf.current = requestAnimationFrame(animTick);
@@ -466,7 +615,7 @@ function VroomChart(props) {
466
615
  const prog = Math.min(1, (now - startTs) / dur);
467
616
  const fade = from + (target - from) * ease(easingRef.current, prog);
468
617
  morphFade.current = fade;
469
- handle.setMorph(fade, fade);
618
+ handle.setMorph(reduceMotion ? 0 : fade, fade);
470
619
  const p = handle.render();
471
620
  if (p) pictureSV.value = p;
472
621
  if (prog < 1) {
@@ -486,7 +635,7 @@ function VroomChart(props) {
486
635
  morphRaf.current = null;
487
636
  }
488
637
  };
489
- }, [handle, chartType, transitionMs, pictureSV]);
638
+ }, [handle, chartType, transitionMs, reduceMotion, pictureSV]);
490
639
  const volumeRaf = useRef2(null);
491
640
  const volumeHandle = useRef2(null);
492
641
  useEffect2(() => {
@@ -504,7 +653,7 @@ function VroomChart(props) {
504
653
  volumeRaf.current = null;
505
654
  }
506
655
  const dur = Math.max(0, transitionMs ?? 300);
507
- if (dur === 0) {
656
+ if (dur === 0 || reduceMotion) {
508
657
  volumeCollapseRef.current = { t: target, easing };
509
658
  handle.setVolumeCollapse(target, easing);
510
659
  const p = handle.render();
@@ -531,8 +680,8 @@ function VroomChart(props) {
531
680
  volumeRaf.current = null;
532
681
  }
533
682
  };
534
- }, [handle, volume?.enabled, transitionMs, pictureSV, volumeCollapseRef]);
535
- const hitAxis = useCallback(
683
+ }, [handle, volume?.enabled, transitionMs, reduceMotion, pictureSV, volumeCollapseRef]);
684
+ const hitAxis = useCallback2(
536
685
  (x, y) => {
537
686
  if (!handle) return "chart";
538
687
  const { yAxisWidth, xAxisHeight, indicatorHeight } = handle.getAxisMetrics();
@@ -545,7 +694,7 @@ function VroomChart(props) {
545
694
  },
546
695
  [handle, width, height]
547
696
  );
548
- const hitPriceLine = useCallback(
697
+ const hitPriceLine = useCallback2(
549
698
  (x, y) => {
550
699
  if (!handle || !priceLines?.length) return null;
551
700
  const hit = handle.hitTestPriceLine(x, y);
@@ -742,6 +891,9 @@ function VroomChart(props) {
742
891
  );
743
892
  }
744
893
  export {
745
- VroomChart
894
+ VroomChart,
895
+ classifyTransition,
896
+ inferStepMs,
897
+ timeframeWindow
746
898
  };
747
899
  //# sourceMappingURL=index.mjs.map