react-native-livechart 3.5.1 → 3.7.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.
@@ -190,6 +190,18 @@ export function computeTooltipLayout(
190
190
  * every frame, and `measureText` shapes text each call (a real cost,
191
191
  * especially in the simulator). Falls back to `measureText` when 0. */
192
192
  monoCharWidth = 0,
193
+ /** Where the pill sits relative to the scrub line. `"side"` offsets it right
194
+ * (flipping left near the edge); `"top"`/`"bottom"` center it over the line,
195
+ * clamped into the plot and pinned to the plot's top/bottom. */
196
+ placement: "side" | "top" | "bottom" = "side",
197
+ /** Render the value row. */
198
+ showValue = true,
199
+ /** Render the time row. */
200
+ showTime = true,
201
+ /** Canvas height in px — needed to pin `"bottom"` placement. */
202
+ canvasHeight = 0,
203
+ /** Gap in px between the pill and the plot edge it's pinned to. */
204
+ margin = TOOLTIP_TOP_MARGIN,
193
205
  ): TooltipLayout {
194
206
  "worklet";
195
207
  if (!scrubActive || scrubValue === null) return HIDDEN_TOOLTIP;
@@ -199,9 +211,19 @@ export function computeTooltipLayout(
199
211
  const valueStr = formatValue(v);
200
212
  const timeStr = formatTime(t);
201
213
 
214
+ // Content toggles: pick which rows render. Guard both-off → keep the time row.
215
+ let sv = showValue;
216
+ let st = showTime;
217
+ let rowCount = (sv ? 1 : 0) + (st ? 1 : 0);
218
+ if (rowCount === 0) {
219
+ st = true;
220
+ rowCount = 1;
221
+ }
222
+
202
223
  const fm = font.getMetrics();
203
224
  const lineH = -fm.ascent + fm.descent;
204
- const totalH = TOOLTIP_PAD_Y * 2 + lineH * 2 + TOOLTIP_LINE_GAP;
225
+ const totalH =
226
+ TOOLTIP_PAD_Y * 2 + lineH * rowCount + TOOLTIP_LINE_GAP * (rowCount - 1);
205
227
 
206
228
  const valueW =
207
229
  monoCharWidth > 0
@@ -211,19 +233,39 @@ export function computeTooltipLayout(
211
233
  monoCharWidth > 0
212
234
  ? timeStr.length * monoCharWidth
213
235
  : measureFontTextWidth(font, timeStr);
214
- const contentW = Math.max(valueW, timeW);
236
+ // Only the visible rows contribute to the pill width.
237
+ const contentW = Math.max(sv ? valueW : 0, st ? timeW : 0);
215
238
  const pillW = contentW + TOOLTIP_PAD_X * 2;
216
239
 
217
240
  const rightEdge = canvasWidth - padding.right;
218
241
 
219
- let pillX = scrubX + TOOLTIP_OFFSET_X;
220
- if (pillX + pillW > rightEdge - TOOLTIP_EDGE_GAP) {
221
- pillX = scrubX - pillW - TOOLTIP_OFFSET_X;
242
+ let pillX: number;
243
+ let pillY: number;
244
+ if (placement === "side") {
245
+ pillX = scrubX + TOOLTIP_OFFSET_X;
246
+ if (pillX + pillW > rightEdge - TOOLTIP_EDGE_GAP) {
247
+ pillX = scrubX - pillW - TOOLTIP_OFFSET_X;
248
+ }
249
+ pillY = padding.top + margin;
250
+ } else {
251
+ // Centered horizontally over the scrub line, clamped into the plot.
252
+ const leftBound = padding.left + TOOLTIP_EDGE_GAP;
253
+ const rightBound = rightEdge - TOOLTIP_EDGE_GAP - pillW;
254
+ pillX = Math.min(
255
+ Math.max(scrubX - pillW / 2, leftBound),
256
+ Math.max(leftBound, rightBound),
257
+ );
258
+ pillY =
259
+ placement === "top"
260
+ ? padding.top + margin
261
+ : canvasHeight - padding.bottom - margin - totalH;
222
262
  }
223
- const pillY = padding.top + TOOLTIP_TOP_MARGIN;
224
263
 
225
- const line1Y = pillY + TOOLTIP_PAD_Y - fm.ascent;
226
- const line2Y = line1Y + lineH + TOOLTIP_LINE_GAP;
264
+ // line1Y = first rendered row's baseline; line2Y = second (when both shown).
265
+ // The overlay maps value→line1Y and time→(showValue ? line2Y : line1Y).
266
+ const firstBaseline = pillY + TOOLTIP_PAD_Y - fm.ascent;
267
+ const line1Y = firstBaseline;
268
+ const line2Y = firstBaseline + lineH + TOOLTIP_LINE_GAP;
227
269
 
228
270
  const valueTextX = pillX + TOOLTIP_PAD_X + (contentW - valueW) / 2;
229
271
  const timeTextX = pillX + TOOLTIP_PAD_X + (contentW - timeW) / 2;
@@ -618,6 +660,11 @@ export function deriveCrosshairTooltipSingle(
618
660
  formatTime: (t: number) => string,
619
661
  font: SkFont,
620
662
  monoCharWidth = 0,
663
+ placement: "side" | "top" | "bottom" = "side",
664
+ showValue = true,
665
+ showTime = true,
666
+ canvasHeight = 0,
667
+ margin = TOOLTIP_TOP_MARGIN,
621
668
  ): TooltipLayout {
622
669
  "worklet";
623
670
  if (!scrubActive || scrubTime < 0) return HIDDEN_TOOLTIP;
@@ -632,5 +679,10 @@ export function deriveCrosshairTooltipSingle(
632
679
  formatTime,
633
680
  font,
634
681
  monoCharWidth,
682
+ placement,
683
+ showValue,
684
+ showTime,
685
+ canvasHeight,
686
+ margin,
635
687
  );
636
688
  }
@@ -129,6 +129,14 @@ export function useCrosshair(
129
129
  * Omitted when nothing coexists.
130
130
  */
131
131
  deferTapHit?: (x: number, y: number) => boolean,
132
+ /** Where the tooltip pill sits relative to the scrub line. Default `"side"`. */
133
+ tooltipPlacement: "side" | "top" | "bottom" = "side",
134
+ /** Render the value row in the default tooltip. Default `true`. */
135
+ tooltipShowValue = true,
136
+ /** Render the time row in the default tooltip. Default `true`. */
137
+ tooltipShowTime = true,
138
+ /** Gap (px) between the tooltip and the plot edge it's pinned to. Default `8`. */
139
+ tooltipMargin = 8,
132
140
  ): CrosshairState {
133
141
  const scrubX = useSharedValue(-1);
134
142
  const scrubActive = useSharedValue(false);
@@ -247,6 +255,11 @@ export function useCrosshair(
247
255
  formatTime,
248
256
  font,
249
257
  monoCharWidth,
258
+ tooltipPlacement,
259
+ tooltipShowValue,
260
+ tooltipShowTime,
261
+ engine.canvasHeight.get(),
262
+ tooltipMargin,
250
263
  );
251
264
  });
252
265
 
package/src/index.ts CHANGED
@@ -79,6 +79,7 @@ export type {
79
79
  ThemeMode,
80
80
  ThresholdConfig,
81
81
  ThresholdLineConfig,
82
+ TooltipRenderProps,
82
83
  TradeEvent,
83
84
  ValueLineConfig,
84
85
  XAxisConfig,
package/src/types.ts CHANGED
@@ -378,6 +378,27 @@ export interface ScrubConfig {
378
378
  tooltipColor?: string;
379
379
  /** Tooltip pill border color. Omit to use theme `tooltipBorder`. */
380
380
  tooltipBorderColor?: string;
381
+ /** Tooltip pill corner radius in px. Default `5`. */
382
+ tooltipBorderRadius?: number;
383
+ /**
384
+ * Where the tooltip pill sits relative to the vertical scrub line.
385
+ * `"side"` (default) offsets it to the right of the line and flips left near
386
+ * the right edge. `"top"` / `"bottom"` center it horizontally over the line,
387
+ * clamped into the plot and pinned to the plot's top or bottom. This also
388
+ * drives a custom {@link LiveChartProps.renderTooltip} so it gets the same
389
+ * placement for free.
390
+ */
391
+ tooltipPlacement?: "side" | "top" | "bottom";
392
+ /**
393
+ * Gap in px between the tooltip and the plot edge it's pinned to — the top for
394
+ * `"side"`/`"top"`, the bottom for `"bottom"`. Applies to the built-in pill and
395
+ * a custom {@link LiveChartProps.renderTooltip}. Default `8`.
396
+ */
397
+ tooltipMargin?: number;
398
+ /** Show the value row in the default tooltip body. Default `true`. */
399
+ tooltipShowValue?: boolean;
400
+ /** Show the time row in the default tooltip body. Default `true`. */
401
+ tooltipShowTime?: boolean;
381
402
  /**
382
403
  * Press-and-hold delay in milliseconds before scrubbing activates — think of
383
404
  * it as "press and hold to scrub." During the delay the pan is not captured,
@@ -473,6 +494,28 @@ export interface SelectionDotProps {
473
494
  size: number;
474
495
  }
475
496
 
497
+ /**
498
+ * Context passed to a custom {@link LiveChartProps.renderTooltip}. The element
499
+ * you return is a React Native view that the chart floats over the canvas and
500
+ * positions on the UI thread (per `scrub.tooltipPlacement`) — so movement stays
501
+ * smooth without JS re-renders, unlike rebuilding the tooltip from the JS-thread
502
+ * `onScrub` callback. Bind the SharedValues here to animated text (e.g. an
503
+ * `Animated.createAnimatedComponent(TextInput)` driven by `useAnimatedProps`)
504
+ * for the value/date to update on the UI thread too.
505
+ */
506
+ export interface TooltipRenderProps {
507
+ /** Interpolated value under the crosshair; `null` when none. */
508
+ value: SharedValue<number | null>;
509
+ /** Window time (unix seconds) under the crosshair. */
510
+ time: SharedValue<number>;
511
+ /** Value formatted with the chart's `formatValue` (computed UI-side). */
512
+ valueStr: SharedValue<string>;
513
+ /** Time formatted with the chart's `formatTime` (computed UI-side). */
514
+ timeStr: SharedValue<string>;
515
+ /** Whether scrubbing is currently active. */
516
+ active: SharedValue<boolean>;
517
+ }
518
+
476
519
  /** Outer ring drawn around the built-in selection dot (the subtle halo). */
477
520
  export interface SelectionDotRingConfig {
478
521
  /** Ring color. Defaults to the dot color. */
@@ -1011,6 +1054,34 @@ export interface LiveChartCoreProps {
1011
1054
  onMarkerHover?: (event: MarkerHoverEvent | null) => void;
1012
1055
  /** Tap hit-test radius in px. Default `16` (≈ 44px touch target with the glyph). */
1013
1056
  markerHitRadius?: number;
1057
+ /**
1058
+ * Render a marker as a custom **React Native** element instead of a built-in
1059
+ * Skia glyph — e.g. an `expo-blur` glass badge or any non-Skia view that the
1060
+ * canvas can't draw. Return an element to float it, auto-centered, at the
1061
+ * marker's live `(time, value)` position (tracked on the UI thread); return
1062
+ * `null`/`undefined` to keep the built-in atlas glyph for that marker.
1063
+ *
1064
+ * The element is rendered as an RN view layered over the canvas, so it is
1065
+ * crisp at native resolution. Use it sparingly (a handful of special markers):
1066
+ * each custom marker is its own animated view + projection, whereas built-in
1067
+ * glyphs batch into a single draw call. Custom-rendered markers skip the atlas
1068
+ * glyph entirely (no double-draw).
1069
+ */
1070
+ renderMarker?: (marker: Marker) => ReactElement | null | undefined;
1071
+ /**
1072
+ * Render a fully custom scrub tooltip as a **React Native** element instead of
1073
+ * the built-in pill — the same idea as `renderMarker`. The chart floats the
1074
+ * returned element over the canvas and positions it on the UI thread per
1075
+ * `scrub.tooltipPlacement` (so it stays smooth, unlike rebuilding the tooltip
1076
+ * from the JS-thread `onScrub`). You own the chrome (border radius/color,
1077
+ * background are plain RN styles) and content — bind the {@link
1078
+ * TooltipRenderProps} SharedValues to animated text for the value/date.
1079
+ *
1080
+ * Return `null`/`undefined` to fall back to the built-in pill. Replaces the
1081
+ * default pill entirely while active. Single-series **line mode only** —
1082
+ * ignored in candle mode (the OHLC stack owns the tooltip there).
1083
+ */
1084
+ renderTooltip?: (ctx: TooltipRenderProps) => ReactElement | null | undefined;
1014
1085
  /**
1015
1086
  * Override individual resolved-palette keys on top of the palette derived from
1016
1087
  * `accentColor` + `theme`. Only the keys you set are replaced.