react-native-livechart 4.4.0 → 4.6.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 (44) hide show
  1. package/dist/components/LiveChart.d.ts.map +1 -1
  2. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  3. package/dist/components/LoadingOverlay.d.ts +9 -1
  4. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  5. package/dist/constants.d.ts +14 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/liveChartEngineTick.d.ts +11 -0
  8. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  9. package/dist/core/liveChartSeriesEngineTick.d.ts +11 -0
  10. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  11. package/dist/core/resolveConfig.d.ts +35 -1
  12. package/dist/core/resolveConfig.d.ts.map +1 -1
  13. package/dist/core/useLiveChartEngine.d.ts +13 -0
  14. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  15. package/dist/core/useLiveChartSeriesEngine.d.ts +13 -0
  16. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  17. package/dist/hooks/useChartPaths.d.ts +5 -1
  18. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  19. package/dist/hooks/useChartReveal.d.ts +3 -1
  20. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  21. package/dist/hooks/useModeBlend.d.ts +4 -1
  22. package/dist/hooks/useModeBlend.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/math/squiggly.d.ts +8 -5
  26. package/dist/math/squiggly.d.ts.map +1 -1
  27. package/dist/types.d.ts +110 -1
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/components/LiveChart.tsx +128 -42
  31. package/src/components/LiveChartSeries.tsx +97 -26
  32. package/src/components/LoadingOverlay.tsx +25 -6
  33. package/src/constants.ts +17 -0
  34. package/src/core/liveChartEngineTick.ts +26 -10
  35. package/src/core/liveChartSeriesEngineTick.ts +22 -4
  36. package/src/core/resolveConfig.ts +63 -0
  37. package/src/core/useLiveChartEngine.ts +40 -1
  38. package/src/core/useLiveChartSeriesEngine.ts +35 -1
  39. package/src/hooks/useChartPaths.ts +11 -1
  40. package/src/hooks/useChartReveal.ts +7 -2
  41. package/src/hooks/useModeBlend.ts +5 -3
  42. package/src/index.ts +2 -0
  43. package/src/math/squiggly.ts +21 -7
  44. package/src/types.ts +112 -1
@@ -6,12 +6,14 @@
6
6
  */
7
7
  import { Canvas, Group } from "@shopify/react-native-skia";
8
8
  import { useLayoutEffect, useState } from "react";
9
- import { View } from "react-native";
9
+ import { StyleSheet, View } from "react-native";
10
10
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
11
- import {
11
+ import Animated, {
12
12
  useAnimatedReaction,
13
+ useAnimatedStyle,
13
14
  useDerivedValue,
14
15
  useSharedValue,
16
+ withTiming,
15
17
  type SharedValue,
16
18
  } from "react-native-reanimated";
17
19
  import { scheduleOnRN } from "react-native-worklets";
@@ -19,6 +21,7 @@ import {
19
21
  DEFAULT_ACCENT_COLOR,
20
22
  HOLD_TO_SCRUB_MS,
21
23
  MAX_MULTI_SERIES,
24
+ SCRUB_OVERLAY_FADE_MS,
22
25
  } from "../constants";
23
26
  import {
24
27
  lineColorsSignatureFromArray,
@@ -33,12 +36,14 @@ import {
33
36
  resolveGridStyle,
34
37
  resolveLeftEdgeFade,
35
38
  resolveLegend,
39
+ resolveLoading,
36
40
  resolveMarkerCluster,
37
41
  resolveMetrics,
38
42
  resolveMultiSeriesDot,
39
43
  resolveReturnToLiveMs,
40
44
  resolveScrub,
41
45
  resolveSelectionDot,
46
+ resolveTransitions,
42
47
  resolveXAxis,
43
48
  resolveYAxis,
44
49
  resolveZoom,
@@ -126,6 +131,8 @@ function useLiveChartSeriesController({
126
131
  timeWindow = 30,
127
132
  paused = false,
128
133
  loading = false,
134
+ transitions,
135
+ snapKey,
129
136
  smoothing = 0.08,
130
137
  exaggerate = false,
131
138
  nonNegative = false,
@@ -289,7 +296,18 @@ function useLiveChartSeriesController({
289
296
  return false;
290
297
  });
291
298
 
292
- const reveal = useChartReveal(loading, hasData);
299
+ // Resolve the loading shell: null = not loading, else the styled config.
300
+ const loadingCfg = resolveLoading(loading);
301
+ const loadingActive = loadingCfg !== null;
302
+ // Multi-series is always lines, so only the reveal transition applies (no
303
+ // candle↔line crossfade); `transitions.mode` is accepted but inert here.
304
+ const transitionsCfg = resolveTransitions(transitions);
305
+ const reveal = useChartReveal(
306
+ loadingActive,
307
+ hasData,
308
+ false,
309
+ transitionsCfg.reveal,
310
+ );
293
311
 
294
312
  const effectiveSeries = useMultiSeriesReverseMorphInputs({
295
313
  series,
@@ -301,6 +319,7 @@ function useLiveChartSeriesController({
301
319
  series: effectiveSeries,
302
320
  timeWindow,
303
321
  paused,
322
+ snapKey,
304
323
  scrollEnabled: timeScrollEnabled,
305
324
  returnToLiveMs,
306
325
  smoothing,
@@ -453,6 +472,23 @@ function useLiveChartSeriesController({
453
472
 
454
473
  const backgroundColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
455
474
 
475
+ // Fade markers + reference lines out while scrubbing when
476
+ // `scrub.hideOverlaysOnScrub` is set. Eased off the scrub-ACTIVE flag (not the
477
+ // crosshair edge fade, which would resurface them near the live dot); only a
478
+ // group opacity animates — the overlay draws stay intact. See `LiveChart`.
479
+ const fadeOverlaysOnScrub =
480
+ scrubCfg !== null && scrubCfg.hideOverlaysOnScrub === true;
481
+ const overlayScrubFade = useDerivedValue(() =>
482
+ fadeOverlaysOnScrub
483
+ ? withTiming(crosshair.scrubActive.get() ? 0 : 1, {
484
+ duration: SCRUB_OVERLAY_FADE_MS,
485
+ })
486
+ : 1,
487
+ );
488
+ const markerGroupOpacity = useDerivedValue(
489
+ () => reveal.dotOpacity.get() * overlayScrubFade.get(),
490
+ );
491
+
456
492
  return {
457
493
  // passthrough props the render needs
458
494
  series,
@@ -484,6 +520,11 @@ function useLiveChartSeriesController({
484
520
  // engine + reveal
485
521
  engine,
486
522
  reveal,
523
+ // loading shell styling (null → not loading)
524
+ loadingLineColor: loadingCfg?.color,
525
+ loadingStrokeWidth: loadingCfg?.strokeWidth,
526
+ loadingAmplitude: loadingCfg?.amplitude,
527
+ loadingSpeed: loadingCfg?.speed,
487
528
  effectiveSeries,
488
529
  layoutHeight,
489
530
  onLayout,
@@ -500,6 +541,8 @@ function useLiveChartSeriesController({
500
541
  markersActive,
501
542
  markersSV,
502
543
  markerClusterCfg,
544
+ markerGroupOpacity,
545
+ overlayScrubFade,
503
546
  renderMarker,
504
547
  // selection dot: resolved config + fallback color (the leading series' color)
505
548
  selectionDot: selectionDotCfg,
@@ -545,10 +588,16 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
545
588
  markersActive,
546
589
  markersSV,
547
590
  markerClusterCfg,
591
+ markerGroupOpacity,
592
+ overlayScrubFade,
548
593
  renderMarker,
549
594
  series,
550
595
  emptyText,
551
596
  metricsCfg,
597
+ loadingLineColor,
598
+ loadingStrokeWidth,
599
+ loadingAmplitude,
600
+ loadingSpeed,
552
601
  } = model;
553
602
 
554
603
  return (
@@ -570,18 +619,21 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
570
619
 
571
620
  {/* Index keys: reference lines are a positional array and two may share
572
621
  value + label (e.g. duplicate working orders at the same price), which a
573
- content-derived key would collapse to one. */}
574
- {allRefLines.map((rl, i) => (
575
- <ReferenceLineOverlay
576
- key={i}
577
- engine={engine}
578
- padding={effectivePadding}
579
- line={rl}
580
- palette={palette}
581
- formatValue={formatValue}
582
- font={skiaFont}
583
- />
584
- ))}
622
+ content-derived key would collapse to one. Fade group lets
623
+ `scrub.hideOverlaysOnScrub` ease the lines out while scrubbing. */}
624
+ <Group opacity={overlayScrubFade}>
625
+ {allRefLines.map((rl, i) => (
626
+ <ReferenceLineOverlay
627
+ key={i}
628
+ engine={engine}
629
+ padding={effectivePadding}
630
+ line={rl}
631
+ palette={palette}
632
+ formatValue={formatValue}
633
+ font={skiaFont}
634
+ />
635
+ ))}
636
+ </Group>
585
637
 
586
638
  {dotCfg.valueLine && (
587
639
  <Group opacity={reveal.lineOpacity}>
@@ -654,7 +706,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
654
706
  )}
655
707
 
656
708
  {markersActive && (
657
- <Group opacity={reveal.dotOpacity}>
709
+ <Group opacity={markerGroupOpacity}>
658
710
  <MarkerOverlay
659
711
  markers={markersSV}
660
712
  engine={engine}
@@ -680,6 +732,10 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
680
732
  strokeWidth={strokeWidth}
681
733
  badge={false}
682
734
  emptyMetrics={metricsCfg.emptyState}
735
+ lineColor={loadingLineColor}
736
+ lineStrokeWidth={loadingStrokeWidth}
737
+ waveAmplitude={loadingAmplitude}
738
+ waveSpeed={loadingSpeed}
683
739
  />
684
740
  </Group>
685
741
  );
@@ -726,10 +782,11 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
726
782
  formatValue,
727
783
  skiaFont,
728
784
  degenShakeTransform,
785
+ overlayScrubFade,
729
786
  } = model;
730
787
  if (allRefLines.length === 0) return null;
731
788
  return (
732
- <Group transform={degenShakeTransform}>
789
+ <Group transform={degenShakeTransform} opacity={overlayScrubFade}>
733
790
  {allRefLines.map((rl, i) => (
734
791
  <ReferenceLineOverlay
735
792
  key={i}
@@ -778,8 +835,15 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
778
835
  markersSV,
779
836
  markerClusterCfg,
780
837
  renderMarker,
838
+ overlayScrubFade,
781
839
  } = model;
782
840
 
841
+ // Mirror the Skia overlay fade onto the RN custom-marker sibling so
842
+ // `scrub.hideOverlaysOnScrub` hides it with the Skia markers.
843
+ const overlayFadeStyle = useAnimatedStyle(() => ({
844
+ opacity: overlayScrubFade.get(),
845
+ }));
846
+
783
847
  // Extend the scrub dim past the plot's right edge to fully cover the series
784
848
  // dots (with their halo) and pulse rings, all centered on that edge. The
785
849
  // gutter reserves room beyond this for the value/Y-axis labels, drawn on top.
@@ -876,16 +940,23 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
876
940
  />
877
941
 
878
942
  {/* Custom-rendered markers — RN views floated over the canvas
879
- (non-Skia), pinned to each marker's live position. */}
943
+ (non-Skia), pinned to each marker's live position. Box-none fade
944
+ wrapper so `scrub.hideOverlaysOnScrub` hides them with the Skia
945
+ markers (full-bleed; children keep their own absolute positions). */}
880
946
  {markersActive && renderMarker && (
881
- <CustomMarkerOverlay
882
- markers={markersSV}
883
- renderMarker={renderMarker}
884
- engine={engine}
885
- padding={effectivePadding}
886
- series={series}
887
- cluster={markerClusterCfg}
888
- />
947
+ <Animated.View
948
+ pointerEvents="box-none"
949
+ style={[StyleSheet.absoluteFill, overlayFadeStyle]}
950
+ >
951
+ <CustomMarkerOverlay
952
+ markers={markersSV}
953
+ renderMarker={renderMarker}
954
+ engine={engine}
955
+ padding={effectivePadding}
956
+ series={series}
957
+ cluster={markerClusterCfg}
958
+ />
959
+ </Animated.View>
889
960
  )}
890
961
  </View>
891
962
  </GestureDetector>
@@ -62,6 +62,10 @@ export function LoadingOverlay({
62
62
  badgeTail = true,
63
63
  badgeMetrics = BADGE_METRICS_DEFAULTS,
64
64
  emptyMetrics = EMPTY_STATE_METRICS_DEFAULTS,
65
+ lineColor,
66
+ lineStrokeWidth,
67
+ waveAmplitude = 14,
68
+ waveSpeed = 1,
65
69
  }: {
66
70
  engine: ChartEngineLayout;
67
71
  padding: ChartPadding;
@@ -73,6 +77,14 @@ export function LoadingOverlay({
73
77
  isEmpty: SharedValue<boolean> | { value: boolean };
74
78
  emptyText: string;
75
79
  strokeWidth: number;
80
+ /** Loading squiggle + skeleton color. Omit → theme `gridLine`. */
81
+ lineColor?: string;
82
+ /** Loading squiggle stroke width. Omit → `strokeWidth`. */
83
+ lineStrokeWidth?: number;
84
+ /** Breathing-wave base amplitude (px). */
85
+ waveAmplitude?: number;
86
+ /** Breathing-wave speed multiplier. */
87
+ waveSpeed?: number;
76
88
  /** Mirror the badge prop so labels align with GridOverlay's label positions. */
77
89
  badge?: boolean;
78
90
  /** Whether the badge tail spike is shown; affects the left inset used for skeleton alignment. */
@@ -86,6 +98,11 @@ export function LoadingOverlay({
86
98
  const leftInset =
87
99
  badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
88
100
 
101
+ // Loading-shell color (squiggle + skeleton placeholders) and squiggle stroke,
102
+ // both overridable via `loading={{ color, strokeWidth }}`.
103
+ const loadingColor = lineColor ?? palette.gridLine;
104
+ const loadingStroke = lineStrokeWidth ?? strokeWidth;
105
+
89
106
  // Squiggly path — built into a reused PathBuilder and detach()-ed each frame.
90
107
  const squigglyBuilder = usePathBuilder();
91
108
 
@@ -100,6 +117,8 @@ export function LoadingOverlay({
100
117
  engine.canvasHeight.get(),
101
118
  padding,
102
119
  engine.timestamp.get(),
120
+ waveAmplitude,
121
+ waveSpeed,
103
122
  );
104
123
  return buildSplineDetached(b, pts);
105
124
  });
@@ -225,8 +244,8 @@ export function LoadingOverlay({
225
244
  <Path
226
245
  path={squigglyPath}
227
246
  style="stroke"
228
- strokeWidth={strokeWidth}
229
- color={palette.gridLine}
247
+ strokeWidth={loadingStroke}
248
+ color={loadingColor}
230
249
  strokeCap="round"
231
250
  strokeJoin="round"
232
251
  />
@@ -255,7 +274,7 @@ export function LoadingOverlay({
255
274
  width={RECT_W}
256
275
  height={RECT_H}
257
276
  r={RECT_R}
258
- color={palette.gridLine}
277
+ color={loadingColor}
259
278
  />
260
279
  <RoundedRect
261
280
  x={lx}
@@ -263,7 +282,7 @@ export function LoadingOverlay({
263
282
  width={RECT_W}
264
283
  height={RECT_H}
265
284
  r={RECT_R}
266
- color={palette.gridLine}
285
+ color={loadingColor}
267
286
  />
268
287
  <RoundedRect
269
288
  x={lx}
@@ -271,7 +290,7 @@ export function LoadingOverlay({
271
290
  width={RECT_W}
272
291
  height={RECT_H}
273
292
  r={RECT_R}
274
- color={palette.gridLine}
293
+ color={loadingColor}
275
294
  />
276
295
  <RoundedRect
277
296
  x={lx}
@@ -279,7 +298,7 @@ export function LoadingOverlay({
279
298
  width={RECT_W}
280
299
  height={RECT_H}
281
300
  r={RECT_R}
282
- color={palette.gridLine}
301
+ color={loadingColor}
283
302
  />
284
303
 
285
304
  {/* Empty state label */}
package/src/constants.ts CHANGED
@@ -31,6 +31,23 @@ export const HOLD_TO_SCRUB_MS = 500;
31
31
  */
32
32
  export const RETURN_TO_LIVE_MS = 450;
33
33
 
34
+ /**
35
+ * Duration (ms) of the fade applied to annotation overlays (markers + reference
36
+ * lines) when `scrub.hideOverlaysOnScrub` is on — how long they take to ease out as a
37
+ * scrub starts and back in on release. Driven by the scrub-active flag, eased on
38
+ * the UI thread. Shared by `LiveChart` and `LiveChartSeries`.
39
+ */
40
+ export const SCRUB_OVERLAY_FADE_MS = 150;
41
+
42
+ /**
43
+ * Base wave amplitude (px) of the breathing loading squiggle — it breathes
44
+ * between `0.4×` and `1.0×` this. Overridable via `loading={{ amplitude }}`.
45
+ */
46
+ export const LOADING_WAVE_AMPLITUDE = 14;
47
+
48
+ /** Default breathing-wave speed multiplier (`1` = built-in cadence). */
49
+ export const LOADING_WAVE_SPEED = 1;
50
+
34
51
  // ─── Metric default tokens (single source of truth for LiveChartMetrics) ─────
35
52
  // The resolved `metrics` config (see resolveMetrics) is assembled from these
36
53
  // objects. Draw/worklet helpers default their metric params to the matching
@@ -62,6 +62,17 @@ export interface EngineTickInput {
62
62
  windowBuffer?: number;
63
63
  /** When true, freeze the viewport timestamp and skip displayWindow lerp */
64
64
  paused?: boolean;
65
+ /**
66
+ * Settle the framing in this one frame instead of easing into it: the time
67
+ * window ({@link EngineTickMutable.displayWindow}), Y-range
68
+ * ({@link EngineTickMutable.displayMin}/{@link EngineTickMutable.displayMax}),
69
+ * and value ({@link EngineTickMutable.displayValue}/{@link EngineTickMutable.edgeValue})
70
+ * jump straight to their targets — `smoothing` is bypassed for this tick only.
71
+ * Set for the single frame after a `snapKey` change so a timeframe / dataset
72
+ * switch lands instantly while live ticks keep their normal smoothing. Leaves
73
+ * the timestamp / pan-scroll state untouched. See {@link LiveChartProps.snapKey}.
74
+ */
75
+ snap?: boolean;
65
76
  /**
66
77
  * Absolute right-edge time (unix seconds) to freeze the window at, or
67
78
  * `null`/`undefined` to follow the live edge. Set by the pan-scroll gesture
@@ -147,6 +158,10 @@ export function tickLiveChartEngineFrame(
147
158
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
148
159
 
149
160
  const speed = input.smoothing;
161
+ // One-shot settle (snapKey change): collapse this frame's easing so the
162
+ // window / range / value land on target instantly, then normal smoothing
163
+ // resumes next frame. See `EngineTickInput.snap`.
164
+ const snap = input.snap === true;
150
165
  let target = input.targetValue;
151
166
  if (input.mode === "candle" && input.liveCandle) {
152
167
  target = input.liveCandle.close;
@@ -160,17 +175,16 @@ export function tickLiveChartEngineFrame(
160
175
  (1 - gapRatio) *
161
176
  (input.adaptiveSpeedBoost ?? MOTION_METRICS_DEFAULTS.adaptiveSpeedBoost);
162
177
 
163
- state.displayValue = lerp(
164
- state.displayValue,
165
- target,
166
- adaptiveSpeed,
167
- input.dt,
168
- );
178
+ state.displayValue = snap
179
+ ? target
180
+ : lerp(state.displayValue, target, adaptiveSpeed, input.dt);
169
181
 
170
182
  // Pinch-zoom: ease toward the zoom override when set, else the configured
171
183
  // window. Mirrors the viewEnd freeze above (width vs. right edge).
172
184
  const targetWindow = input.viewWindow ?? input.timeWindow;
173
- state.displayWindow = lerp(state.displayWindow, targetWindow, speed, input.dt);
185
+ state.displayWindow = snap
186
+ ? targetWindow
187
+ : lerp(state.displayWindow, targetWindow, speed, input.dt);
174
188
 
175
189
  const winStart = state.timestamp - state.displayWindow;
176
190
 
@@ -292,13 +306,13 @@ export function tickLiveChartEngineFrame(
292
306
  const maxV = input.maxValue;
293
307
  if (maxV !== undefined && tMax > maxV) tMax = maxV;
294
308
 
295
- if (tMin < state.displayMin) {
309
+ if (snap || tMin < state.displayMin) {
296
310
  state.displayMin = tMin;
297
311
  } else {
298
312
  state.displayMin = lerp(state.displayMin, tMin, speed, input.dt);
299
313
  }
300
314
 
301
- if (tMax > state.displayMax) {
315
+ if (snap || tMax > state.displayMax) {
302
316
  state.displayMax = tMax;
303
317
  } else {
304
318
  state.displayMax = lerp(state.displayMax, tMax, speed, input.dt);
@@ -338,6 +352,8 @@ export function tickLiveChartEngineFrame(
338
352
  }
339
353
  if (elo > 0) edgeTarget = pts[elo - 1].value;
340
354
  }
341
- state.edgeValue = lerp(state.edgeValue, edgeTarget, speed, input.dt);
355
+ state.edgeValue = snap
356
+ ? edgeTarget
357
+ : lerp(state.edgeValue, edgeTarget, speed, input.dt);
342
358
  }
343
359
  }
@@ -50,6 +50,17 @@ export interface MultiEngineTickInput {
50
50
  /** Right-edge buffer as a fraction of the time window. */
51
51
  windowBuffer?: number;
52
52
  paused?: boolean;
53
+ /**
54
+ * Settle the framing in this one frame instead of easing into it: the time
55
+ * window, Y-range ({@link MultiEngineTickMutable.displayMin}/{@link MultiEngineTickMutable.displayMax}),
56
+ * and per-series tips ({@link MultiEngineTickMutable.displayValues}) jump
57
+ * straight to their targets — `smoothing` is bypassed for this tick only. Set
58
+ * for the single frame after a `snapKey` change so a timeframe / dataset switch
59
+ * lands instantly while live ticks keep their normal smoothing. Series-toggle
60
+ * opacities and the timestamp / pan-scroll state are left untouched. See
61
+ * {@link LiveChartSeriesProps.snapKey}.
62
+ */
63
+ snap?: boolean;
53
64
  /**
54
65
  * Absolute right-edge time (unix seconds) to freeze the window at, or
55
66
  * `null`/`undefined` to follow the live edge. Drives pan-scroll; takes
@@ -122,6 +133,9 @@ export function tickLiveChartSeriesEngineFrame(
122
133
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
123
134
 
124
135
  const speed = input.smoothing;
136
+ // One-shot settle (snapKey change): collapse this frame's easing so the
137
+ // window / range / tips land on target instantly. See `MultiEngineTickInput.snap`.
138
+ const snap = input.snap === true;
125
139
  const series = input.series;
126
140
  const n = series.length;
127
141
 
@@ -138,7 +152,9 @@ export function tickLiveChartSeriesEngineFrame(
138
152
  // Pinch-zoom: ease toward the zoom override when set, else the configured
139
153
  // window (mirrors the single-series tick).
140
154
  const targetWindow = input.viewWindow ?? input.timeWindow;
141
- state.displayWindow = lerp(state.displayWindow, targetWindow, speed, input.dt);
155
+ state.displayWindow = snap
156
+ ? targetWindow
157
+ : lerp(state.displayWindow, targetWindow, speed, input.dt);
142
158
 
143
159
  const winStart = state.timestamp - state.displayWindow;
144
160
  const range = state.displayMax - state.displayMin;
@@ -170,7 +186,9 @@ export function tickLiveChartSeriesEngineFrame(
170
186
  (1 - gapRatio) *
171
187
  (input.adaptiveSpeedBoost ??
172
188
  MOTION_METRICS_DEFAULTS.adaptiveSpeedBoost);
173
- state.displayValues[i] = lerp(cur, target, adaptiveSpeed, input.dt);
189
+ state.displayValues[i] = snap
190
+ ? target
191
+ : lerp(cur, target, adaptiveSpeed, input.dt);
174
192
 
175
193
  const targetOp = series[i].visible !== false ? 1 : 0;
176
194
  state.opacities[i] = lerp(state.opacities[i], targetOp, speed, input.dt);
@@ -261,13 +279,13 @@ export function tickLiveChartSeriesEngineFrame(
261
279
  const maxV = input.maxValue;
262
280
  if (maxV !== undefined && tMax > maxV) tMax = maxV;
263
281
 
264
- if (tMin < state.displayMin) {
282
+ if (snap || tMin < state.displayMin) {
265
283
  state.displayMin = tMin;
266
284
  } else {
267
285
  state.displayMin = lerp(state.displayMin, tMin, speed, input.dt);
268
286
  }
269
287
 
270
- if (tMax > state.displayMax) {
288
+ if (snap || tMax > state.displayMax) {
271
289
  state.displayMax = tMax;
272
290
  } else {
273
291
  state.displayMax = lerp(state.displayMax, tMax, speed, input.dt);
@@ -14,6 +14,7 @@ import type {
14
14
  LineStyleConfig,
15
15
  LiveChartMetrics,
16
16
  LiveChartMetricsOverride,
17
+ LoadingConfig,
17
18
  MarkerClusterConfig,
18
19
  DotConfig,
19
20
  DotRingConfig,
@@ -29,6 +30,7 @@ import type {
29
30
  ThresholdConfig,
30
31
  ThresholdLineConfig,
31
32
  TradeEvent,
33
+ TransitionConfig,
32
34
  ValueLineConfig,
33
35
  VolumeConfig,
34
36
  XAxisConfig,
@@ -45,6 +47,8 @@ import {
45
47
  EMPTY_STATE_METRICS_DEFAULTS,
46
48
  FADE_EDGE_WIDTH,
47
49
  GRID_METRICS_DEFAULTS,
50
+ LOADING_WAVE_AMPLITUDE,
51
+ LOADING_WAVE_SPEED,
48
52
  MOTION_METRICS_DEFAULTS,
49
53
  RETURN_TO_LIVE_MS,
50
54
  } from "../constants";
@@ -165,6 +169,8 @@ export interface ResolvedScrubConfig {
165
169
  tooltipShowTime: boolean;
166
170
  /** Press-and-hold delay (ms) before scrubbing activates. 0 = immediate. */
167
171
  panGestureDelay: number;
172
+ /** Fade markers + reference lines out while scrubbing. */
173
+ hideOverlaysOnScrub: boolean;
168
174
  }
169
175
 
170
176
  export interface ResolvedScrubActionConfig {
@@ -477,6 +483,32 @@ export function resolveReturnToLiveMs(
477
483
  return d > 0 ? d : 0;
478
484
  }
479
485
 
486
+ /**
487
+ * Resolved transition durations. `undefined` for a field means "use the
488
+ * component's built-in default" (so we don't duplicate the default constants
489
+ * here); a number is an explicit duration in ms (clamped to ≥ 0).
490
+ */
491
+ export interface ResolvedTransitionConfig {
492
+ reveal: number | undefined;
493
+ mode: number | undefined;
494
+ }
495
+
496
+ const clampMs = (v: number | undefined): number | undefined =>
497
+ v == null ? undefined : v > 0 ? v : 0;
498
+
499
+ /**
500
+ * Resolves the `transitions` prop. `false` → all transitions instant (`0`);
501
+ * `true` / omitted → defaults (both `undefined` = use the built-in durations);
502
+ * an object → per-transition overrides (an omitted field keeps its default).
503
+ */
504
+ export function resolveTransitions(
505
+ prop: boolean | TransitionConfig | undefined,
506
+ ): ResolvedTransitionConfig {
507
+ if (prop === false) return { reveal: 0, mode: 0 };
508
+ if (prop == null || prop === true) return { reveal: undefined, mode: undefined };
509
+ return { reveal: clampMs(prop.reveal), mode: clampMs(prop.mode) };
510
+ }
511
+
480
512
  const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
481
513
  format: undefined,
482
514
  color: undefined,
@@ -571,6 +603,7 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
571
603
  tooltipShowValue: true,
572
604
  tooltipShowTime: true,
573
605
  panGestureDelay: 0,
606
+ hideOverlaysOnScrub: false,
574
607
  };
575
608
 
576
609
  /**
@@ -590,6 +623,36 @@ export function resolveScrub(
590
623
  return resolved;
591
624
  }
592
625
 
626
+ export interface ResolvedLoadingConfig {
627
+ /** undefined → palette.gridLine */
628
+ color: string | undefined;
629
+ /** undefined → the chart's line strokeWidth */
630
+ strokeWidth: number | undefined;
631
+ /** Base breathing-wave amplitude (px). */
632
+ amplitude: number;
633
+ /** Breathing-wave speed multiplier. */
634
+ speed: number;
635
+ }
636
+
637
+ const LOADING_DEFAULTS: ResolvedLoadingConfig = {
638
+ color: undefined,
639
+ strokeWidth: undefined,
640
+ amplitude: LOADING_WAVE_AMPLITUDE,
641
+ speed: LOADING_WAVE_SPEED,
642
+ };
643
+
644
+ /**
645
+ * Resolves the `loading` prop to a fully-typed config or null (not loading).
646
+ * `true` → defaults (loading on, built-in look), object → merged with defaults
647
+ * (loading on, restyled), `false` / omitted → null. So a non-null result is the
648
+ * "is loading" flag and carries the resolved styling.
649
+ */
650
+ export function resolveLoading(
651
+ prop: boolean | LoadingConfig | undefined,
652
+ ): ResolvedLoadingConfig | null {
653
+ return resolveToggle(prop, LOADING_DEFAULTS, false);
654
+ }
655
+
593
656
  const SCRUB_ACTION_DEFAULTS: ResolvedScrubActionConfig = {
594
657
  icon: "+",
595
658
  background: undefined,