react-native-livechart 3.2.0 → 3.4.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/README.md +6 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/ReferenceLineOverlay.d.ts +10 -3
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/ScrubActionOverlay.d.ts +32 -0
- package/dist/components/ScrubActionOverlay.d.ts.map +1 -0
- package/dist/components/SegmentDividerOverlay.d.ts +19 -0
- package/dist/components/SegmentDividerOverlay.d.ts.map +1 -0
- package/dist/components/XAxisOverlay.d.ts.map +1 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +23 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/resolveSegment.d.ts +42 -0
- package/dist/core/resolveSegment.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +110 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +14 -2
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts +2 -0
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/useReferenceLine.d.ts +36 -3
- package/dist/hooks/useReferenceLine.d.ts.map +1 -1
- package/dist/hooks/useReferenceLinePress.d.ts +23 -0
- package/dist/hooks/useReferenceLinePress.d.ts.map +1 -0
- package/dist/hooks/useSegmentDivider.d.ts +28 -0
- package/dist/hooks/useSegmentDivider.d.ts.map +1 -0
- package/dist/hooks/useSegmentLineGradient.d.ts +20 -0
- package/dist/hooks/useSegmentLineGradient.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/referenceLines.d.ts +23 -0
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/math/segments.d.ts +46 -0
- package/dist/math/segments.d.ts.map +1 -0
- package/dist/types.d.ts +184 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +242 -26
- package/src/components/LiveChartSeries.tsx +39 -2
- package/src/components/ReferenceLineOverlay.tsx +121 -94
- package/src/components/ScrubActionOverlay.tsx +187 -0
- package/src/components/SegmentDividerOverlay.tsx +84 -0
- package/src/components/XAxisOverlay.tsx +2 -1
- package/src/constants.ts +5 -0
- package/src/core/resolveConfig.ts +40 -0
- package/src/core/resolveSegment.ts +62 -0
- package/src/hooks/crosshairShared.ts +293 -0
- package/src/hooks/useCrosshair.ts +340 -6
- package/src/hooks/useMarkers.ts +18 -2
- package/src/hooks/useReferenceLine.ts +259 -14
- package/src/hooks/useReferenceLinePress.ts +92 -0
- package/src/hooks/useSegmentDivider.ts +84 -0
- package/src/hooks/useSegmentLineGradient.ts +57 -0
- package/src/index.ts +4 -0
- package/src/math/referenceLines.ts +55 -0
- package/src/math/segments.ts +173 -0
- package/src/types.ts +192 -6
|
@@ -8,20 +8,77 @@ import {
|
|
|
8
8
|
type SharedValue,
|
|
9
9
|
} from "react-native-reanimated";
|
|
10
10
|
import { scheduleOnRN } from "react-native-worklets";
|
|
11
|
+
import { BADGE_METRICS_DEFAULTS, X_AXIS_LABEL_OFFSET_Y } from "../constants";
|
|
12
|
+
import type { ResolvedScrubActionConfig } from "../core/resolveConfig";
|
|
11
13
|
import type { SingleEngineState } from "../core/useLiveChartEngine";
|
|
12
14
|
import { type ChartPadding } from "../draw/line";
|
|
13
15
|
import { interpolateAtTime } from "../math/interpolate";
|
|
14
16
|
import { pickCandleAtTime } from "../math/pickCandle";
|
|
15
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
BadgeMetrics,
|
|
19
|
+
CandlePoint,
|
|
20
|
+
LiveChartPalette,
|
|
21
|
+
ScrubActionPoint,
|
|
22
|
+
ScrubPoint,
|
|
23
|
+
} from "../types";
|
|
16
24
|
import {
|
|
25
|
+
computeActionBadgeLayout,
|
|
17
26
|
computeCandleTooltipLayout,
|
|
18
27
|
computeCrosshairOpacity,
|
|
19
28
|
computeScrubDotY,
|
|
20
29
|
computeScrubTime,
|
|
30
|
+
computeTimeBadgeLayout,
|
|
31
|
+
computeValueAtY,
|
|
21
32
|
deriveCrosshairTooltipSingle,
|
|
33
|
+
HIDDEN_ACTION_BADGE,
|
|
34
|
+
HIDDEN_TIME_BADGE,
|
|
35
|
+
pointInRect,
|
|
36
|
+
snapPrice,
|
|
22
37
|
type CrosshairState,
|
|
23
38
|
} from "./crosshairShared";
|
|
24
39
|
|
|
40
|
+
const ACTION_HIT_SLOP = 6;
|
|
41
|
+
const RETICLE_HIT = 14;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Movement (px) a scrub-action *tap* (place / press / dismiss) may travel before
|
|
45
|
+
* it's treated as a drag — the Tap's `maxDistance`. ~Touch slop, so a finger that
|
|
46
|
+
* jitters a few px still registers as a tap rather than failing.
|
|
47
|
+
*/
|
|
48
|
+
const SCRUB_ACTION_TAP_SLOP = 10;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default press-hold (ms) before the live scrub / lock-adjust pan activates in
|
|
52
|
+
* scrub-action ("order ticket") mode. A quick tap places or acts on the reticle;
|
|
53
|
+
* the scrub crosshair only appears on a deliberate hold, so a tap never flashes
|
|
54
|
+
* it. Overridden by an explicit `scrub.panGestureDelay`.
|
|
55
|
+
*/
|
|
56
|
+
const SCRUB_ACTION_PRESS_HOLD_MS = 200;
|
|
57
|
+
|
|
58
|
+
/** Clamp an X pixel to the plot's horizontal bounds. */
|
|
59
|
+
/* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
|
|
60
|
+
function clampPlotX(
|
|
61
|
+
x: number,
|
|
62
|
+
padLeft: number,
|
|
63
|
+
canvasWidth: number,
|
|
64
|
+
padRight: number,
|
|
65
|
+
): number {
|
|
66
|
+
"worklet";
|
|
67
|
+
return Math.min(canvasWidth - padRight, Math.max(padLeft, x));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Clamp a Y pixel to the plot's vertical bounds. */
|
|
71
|
+
/* istanbul ignore next -- worklet, called only from UI-thread gesture handlers */
|
|
72
|
+
function clampPlotY(
|
|
73
|
+
y: number,
|
|
74
|
+
padTop: number,
|
|
75
|
+
canvasHeight: number,
|
|
76
|
+
padBottom: number,
|
|
77
|
+
): number {
|
|
78
|
+
"worklet";
|
|
79
|
+
return Math.min(canvasHeight - padBottom, Math.max(padTop, y));
|
|
80
|
+
}
|
|
81
|
+
|
|
25
82
|
export type { CrosshairState, TooltipLayout } from "./crosshairShared";
|
|
26
83
|
export type { ScrubPoint };
|
|
27
84
|
|
|
@@ -60,6 +117,18 @@ export function useCrosshair(
|
|
|
60
117
|
panGestureDelay = 0,
|
|
61
118
|
onGestureStart?: () => void,
|
|
62
119
|
onGestureEnd?: () => void,
|
|
120
|
+
/** Scrub-action ("order ticket") config; `null`/omitted disables lock mode. */
|
|
121
|
+
scrubAction?: ResolvedScrubActionConfig | null,
|
|
122
|
+
onScrubAction?: (point: ScrubActionPoint) => void,
|
|
123
|
+
/** Badge geometry (gutter margins / pad) for the action pill. */
|
|
124
|
+
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
125
|
+
/**
|
|
126
|
+
* Worklet predicate: true when a tap at (x, y) lands on another interactive
|
|
127
|
+
* overlay (a marker, or a pressable reference-line badge). The scrub-action tap
|
|
128
|
+
* defers to it (no reticle placed) so that overlay's tap handler can act.
|
|
129
|
+
* Omitted when nothing coexists.
|
|
130
|
+
*/
|
|
131
|
+
deferTapHit?: (x: number, y: number) => boolean,
|
|
63
132
|
): CrosshairState {
|
|
64
133
|
const scrubX = useSharedValue(-1);
|
|
65
134
|
const scrubActive = useSharedValue(false);
|
|
@@ -67,6 +136,22 @@ export function useCrosshair(
|
|
|
67
136
|
// activates doesn't emit a spurious onGestureEnd.
|
|
68
137
|
const gestureStarted = useSharedValue(false);
|
|
69
138
|
|
|
139
|
+
// Scrub-action lock state. Created unconditionally (hooks must be), but the
|
|
140
|
+
// lock gestures are only wired by the controller when `scrubAction` is set.
|
|
141
|
+
const lockActive = useSharedValue(false);
|
|
142
|
+
const lockX = useSharedValue(-1);
|
|
143
|
+
// The chosen PRICE is the source of truth (frozen on place/drag); the reticle's
|
|
144
|
+
// screen Y is re-derived from it each frame (see `lockY` below) so the level
|
|
145
|
+
// tracks the price as the axis rescales instead of drifting under a fixed pixel.
|
|
146
|
+
const lockPriceValue = useSharedValue<number | null>(null);
|
|
147
|
+
const hasScrubAction = scrubAction != null;
|
|
148
|
+
const hasOnScrubAction = onScrubAction != null;
|
|
149
|
+
const dismissOnTapOutside = scrubAction?.dismissOnTapOutside ?? false;
|
|
150
|
+
const snapIncrement = scrubAction?.snap;
|
|
151
|
+
const actionIcon = scrubAction?.icon ?? "+";
|
|
152
|
+
const actionShowText = scrubAction?.text ?? true;
|
|
153
|
+
const hasTimeBadge = scrubAction?.timeBadge ?? false;
|
|
154
|
+
|
|
70
155
|
const scrubTime = useDerivedValue(() =>
|
|
71
156
|
computeScrubTime(
|
|
72
157
|
scrubActive.get(),
|
|
@@ -165,6 +250,113 @@ export function useCrosshair(
|
|
|
165
250
|
);
|
|
166
251
|
});
|
|
167
252
|
|
|
253
|
+
// ── Scrub-action lock derivations ──────────────────────────────────────────
|
|
254
|
+
// Reported price = the frozen chosen price, optionally snapped. Independent of
|
|
255
|
+
// the display range (the point of freezing it), so the badge readout and the
|
|
256
|
+
// onScrubAction payload stay stable while the chart auto-rescales. Null until a
|
|
257
|
+
// reticle is placed.
|
|
258
|
+
/* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
|
|
259
|
+
const lockPrice = useDerivedValue(() => {
|
|
260
|
+
if (!lockActive.get()) return null;
|
|
261
|
+
const p = lockPriceValue.get();
|
|
262
|
+
return p === null ? null : snapPrice(p, snapIncrement);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Screen Y of the locked level, DERIVED from the frozen price each frame so the
|
|
266
|
+
// line + badge track the chosen price as displayMin/Max move (rather than the
|
|
267
|
+
// price drifting under a pixel-fixed reticle). Pins to the plot edge when the
|
|
268
|
+
// price scrolls out of the visible range; -1 until placed / laid out.
|
|
269
|
+
/* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
|
|
270
|
+
const lockY = useDerivedValue(() => {
|
|
271
|
+
const p = lockPriceValue.get();
|
|
272
|
+
const ch = engine.canvasHeight.get();
|
|
273
|
+
if (p === null || ch - padding.top - padding.bottom <= 0) return -1;
|
|
274
|
+
const y = computeScrubDotY(
|
|
275
|
+
p,
|
|
276
|
+
engine.displayMin.get(),
|
|
277
|
+
engine.displayMax.get(),
|
|
278
|
+
ch,
|
|
279
|
+
padding.top,
|
|
280
|
+
padding.bottom,
|
|
281
|
+
);
|
|
282
|
+
return clampPlotY(y, padding.top, ch, padding.bottom);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const lockTime = useDerivedValue(() =>
|
|
286
|
+
computeScrubTime(
|
|
287
|
+
lockActive.get(),
|
|
288
|
+
lockX.get(),
|
|
289
|
+
padding,
|
|
290
|
+
engine.canvasWidth.get(),
|
|
291
|
+
engine.timestamp.get(),
|
|
292
|
+
engine.displayWindow.get(),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
/* istanbul ignore next -- worklet */
|
|
297
|
+
const lockCandle = useDerivedValue(() => {
|
|
298
|
+
if (!isCandleMode || !candlesSV || !lockActive.get() || lockTime.get() < 0)
|
|
299
|
+
return null;
|
|
300
|
+
return pickCandleAtTime(
|
|
301
|
+
candlesSV.get(),
|
|
302
|
+
liveCandleSV?.get() ?? null,
|
|
303
|
+
lockTime.get(),
|
|
304
|
+
candleWidthSecs,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Right-gutter action-badge layout — also the tap hit rect. Hidden when not locked.
|
|
309
|
+
/* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
|
|
310
|
+
const actionBadge = useDerivedValue(() => {
|
|
311
|
+
const price = lockPrice.get();
|
|
312
|
+
if (price === null) return HIDDEN_ACTION_BADGE;
|
|
313
|
+
return computeActionBadgeLayout(
|
|
314
|
+
lockActive.get(),
|
|
315
|
+
lockY.get(),
|
|
316
|
+
actionShowText ? formatValue(price) : "",
|
|
317
|
+
actionIcon,
|
|
318
|
+
engine.canvasWidth.get(),
|
|
319
|
+
engine.canvasWidth.get() - padding.right,
|
|
320
|
+
font,
|
|
321
|
+
badgeMetrics.marginEdge,
|
|
322
|
+
badgeMetrics.padX,
|
|
323
|
+
badgeMetrics.padY,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// X-axis time badge at the reticle (opt-in). Hidden unless enabled + locked.
|
|
328
|
+
/* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
|
|
329
|
+
const timeBadge = useDerivedValue(() => {
|
|
330
|
+
if (!hasTimeBadge || !lockActive.get()) return HIDDEN_TIME_BADGE;
|
|
331
|
+
const t = lockTime.get();
|
|
332
|
+
if (t < 0) return HIDDEN_TIME_BADGE;
|
|
333
|
+
return computeTimeBadgeLayout(
|
|
334
|
+
lockActive.get(),
|
|
335
|
+
lockX.get(),
|
|
336
|
+
formatTime(t),
|
|
337
|
+
engine.canvasWidth.get(),
|
|
338
|
+
engine.canvasHeight.get() - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
|
|
339
|
+
font,
|
|
340
|
+
badgeMetrics.padX,
|
|
341
|
+
badgeMetrics.padY,
|
|
342
|
+
badgeMetrics.marginEdge,
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
|
|
347
|
+
function handleScrubAction(
|
|
348
|
+
price: number,
|
|
349
|
+
time: number,
|
|
350
|
+
x: number,
|
|
351
|
+
y: number,
|
|
352
|
+
candleJson: string | null,
|
|
353
|
+
) {
|
|
354
|
+
const candle: CandlePoint | undefined = candleJson
|
|
355
|
+
? (JSON.parse(candleJson) as CandlePoint)
|
|
356
|
+
: undefined;
|
|
357
|
+
onScrubAction?.({ price, time, x, y, candle });
|
|
358
|
+
}
|
|
359
|
+
|
|
168
360
|
/* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
|
|
169
361
|
function handleScrub(
|
|
170
362
|
x: number,
|
|
@@ -246,9 +438,25 @@ export function useCrosshair(
|
|
|
246
438
|
},
|
|
247
439
|
);
|
|
248
440
|
|
|
441
|
+
// In scrub-action mode the live scrub / lock-adjust requires a deliberate
|
|
442
|
+
// press-hold: a quick tap (even a sloppy one that travels a few px) places or
|
|
443
|
+
// acts on the reticle, and must never flash the crosshair. The press-hold is
|
|
444
|
+
// what gates activation — and because movement during the hold *fails* the pan
|
|
445
|
+
// (RNGH), a tap can't trip the scrub by moving. Honor an explicit
|
|
446
|
+
// `scrub.panGestureDelay`, else default to the hold. Outside scrub-action the
|
|
447
|
+
// pan keeps its normal (no-hold) activation unless a delay was set.
|
|
448
|
+
const longPressMs =
|
|
449
|
+
hasScrubAction && panGestureDelay <= 0
|
|
450
|
+
? SCRUB_ACTION_PRESS_HOLD_MS
|
|
451
|
+
: panGestureDelay;
|
|
452
|
+
|
|
249
453
|
let gesture = Gesture.Pan()
|
|
250
|
-
|
|
251
|
-
|
|
454
|
+
// Scrub-action: minDistance 0 — the press-hold above is the sole activator
|
|
455
|
+
// (movement during the hold fails the pan), so the crosshair never appears
|
|
456
|
+
// from a tap's travel. The Tap (maxDistance SCRUB_ACTION_TAP_SLOP) owns quick
|
|
457
|
+
// gestures; only a held press becomes a scrub/adjust drag.
|
|
458
|
+
.minDistance(!hasScrubAction && Platform.OS === "android" ? 10 : 0)
|
|
459
|
+
.activateAfterLongPress(longPressMs)
|
|
252
460
|
.maxPointers(1)
|
|
253
461
|
.shouldCancelWhenOutside(false)
|
|
254
462
|
// Start scrubbing on ACTIVE (onStart), not on touch-down (onBegin):
|
|
@@ -259,6 +467,28 @@ export function useCrosshair(
|
|
|
259
467
|
/* istanbul ignore next */ (e) => {
|
|
260
468
|
"worklet";
|
|
261
469
|
if (!enabled) return;
|
|
470
|
+
// Scrub-action: once a reticle is placed, drag adjusts it (2D — the Y is
|
|
471
|
+
// the price). The ephemeral live-scrub never engages, so scrubActive
|
|
472
|
+
// stays false and the live crosshair hides itself.
|
|
473
|
+
if (hasScrubAction && lockActive.get()) {
|
|
474
|
+
lockX.set(
|
|
475
|
+
clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right),
|
|
476
|
+
);
|
|
477
|
+
// Freeze the chosen price (value at the pointer Y); the line's Y is
|
|
478
|
+
// re-derived from it so the level stays put in PRICE as the axis rescales.
|
|
479
|
+
{
|
|
480
|
+
const p = computeValueAtY(
|
|
481
|
+
e.y,
|
|
482
|
+
engine.displayMin.get(),
|
|
483
|
+
engine.displayMax.get(),
|
|
484
|
+
engine.canvasHeight.get(),
|
|
485
|
+
padding.top,
|
|
486
|
+
padding.bottom,
|
|
487
|
+
);
|
|
488
|
+
if (p !== null) lockPriceValue.set(p);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
262
492
|
scrubX.set(e.x);
|
|
263
493
|
scrubActive.set(true);
|
|
264
494
|
gestureStarted.set(true);
|
|
@@ -269,14 +499,38 @@ export function useCrosshair(
|
|
|
269
499
|
/* istanbul ignore next */ (e) => {
|
|
270
500
|
"worklet";
|
|
271
501
|
if (!enabled) return;
|
|
502
|
+
if (hasScrubAction && lockActive.get()) {
|
|
503
|
+
lockX.set(
|
|
504
|
+
clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right),
|
|
505
|
+
);
|
|
506
|
+
// Freeze the chosen price (value at the pointer Y); the line's Y is
|
|
507
|
+
// re-derived from it so the level stays put in PRICE as the axis rescales.
|
|
508
|
+
{
|
|
509
|
+
const p = computeValueAtY(
|
|
510
|
+
e.y,
|
|
511
|
+
engine.displayMin.get(),
|
|
512
|
+
engine.displayMax.get(),
|
|
513
|
+
engine.canvasHeight.get(),
|
|
514
|
+
padding.top,
|
|
515
|
+
padding.bottom,
|
|
516
|
+
);
|
|
517
|
+
if (p !== null) lockPriceValue.set(p);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
272
521
|
scrubX.set(e.x);
|
|
273
522
|
},
|
|
274
523
|
)
|
|
275
524
|
.onFinalize(
|
|
276
525
|
/* istanbul ignore next */ () => {
|
|
277
526
|
"worklet";
|
|
278
|
-
scrubActive
|
|
279
|
-
|
|
527
|
+
// A lock-adjust drag leaves the reticle in place (scrubActive was never
|
|
528
|
+
// set); a live-scrub clears its crosshair. Always clear scrubActive so a
|
|
529
|
+
// stray scrub can never linger behind a placed reticle.
|
|
530
|
+
if (scrubActive.get()) {
|
|
531
|
+
scrubActive.set(false);
|
|
532
|
+
if (hasOnScrub) scheduleOnRN(handleScrubEnd);
|
|
533
|
+
}
|
|
280
534
|
if (gestureStarted.get()) {
|
|
281
535
|
gestureStarted.set(false);
|
|
282
536
|
if (hasOnGestureEnd) scheduleOnRN(handleGestureEnd);
|
|
@@ -285,10 +539,83 @@ export function useCrosshair(
|
|
|
285
539
|
);
|
|
286
540
|
|
|
287
541
|
/* istanbul ignore next -- Android-only gesture axis config */
|
|
288
|
-
if (Platform.OS === "android") {
|
|
542
|
+
if (Platform.OS === "android" && !hasScrubAction) {
|
|
543
|
+
// Lock mode needs free vertical drag (Y = price), so the failOffsetY clamp —
|
|
544
|
+
// which would kill a vertical adjust — is applied only outside scrub-action.
|
|
289
545
|
gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
|
|
290
546
|
}
|
|
291
547
|
|
|
548
|
+
// Tap: place/move the reticle, press the action badge, or dismiss the lock.
|
|
549
|
+
// Composed ahead of the pan by the controller, so a tap is never swallowed.
|
|
550
|
+
// Built only in scrub-action mode (the plain-scrub path never constructs a Tap).
|
|
551
|
+
/* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
|
|
552
|
+
const handleActionTap = (e: { x: number; y: number }, success: boolean) => {
|
|
553
|
+
"worklet";
|
|
554
|
+
if (!enabled || !hasScrubAction || !success) return;
|
|
555
|
+
if (lockActive.get()) {
|
|
556
|
+
// 1. Action-badge press → fire onScrubAction with the chosen price.
|
|
557
|
+
const rect = actionBadge.get();
|
|
558
|
+
if (rect.visible && pointInRect(e.x, e.y, rect, ACTION_HIT_SLOP)) {
|
|
559
|
+
const price = lockPrice.get();
|
|
560
|
+
if (price !== null) {
|
|
561
|
+
let candleJson: string | null = null;
|
|
562
|
+
if (isCandleMode) {
|
|
563
|
+
const c = lockCandle.get();
|
|
564
|
+
if (c) candleJson = JSON.stringify(c);
|
|
565
|
+
}
|
|
566
|
+
if (hasOnScrubAction)
|
|
567
|
+
scheduleOnRN(
|
|
568
|
+
handleScrubAction,
|
|
569
|
+
price,
|
|
570
|
+
lockTime.get(),
|
|
571
|
+
lockX.get(),
|
|
572
|
+
lockY.get(),
|
|
573
|
+
candleJson,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// 2. Dismiss: tap the reticle itself, or any empty tap when configured.
|
|
579
|
+
const onReticle =
|
|
580
|
+
Math.abs(e.x - lockX.get()) <= RETICLE_HIT &&
|
|
581
|
+
Math.abs(e.y - lockY.get()) <= RETICLE_HIT;
|
|
582
|
+
if (onReticle || dismissOnTapOutside) {
|
|
583
|
+
lockActive.set(false);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// 2.5. Defer to a marker / reference-line badge under the tap: when one
|
|
588
|
+
// coexists, the tap fires both this handler and that overlay's tap
|
|
589
|
+
// (Simultaneous) — yield so it acts instead of dropping a reticle on top.
|
|
590
|
+
if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
|
|
591
|
+
// 3. Place / move the reticle. Clear any in-progress live-scrub so its
|
|
592
|
+
// crosshair never shows behind the placed reticle.
|
|
593
|
+
scrubActive.set(false);
|
|
594
|
+
lockX.set(
|
|
595
|
+
clampPlotX(e.x, padding.left, engine.canvasWidth.get(), padding.right),
|
|
596
|
+
);
|
|
597
|
+
// Freeze the chosen price at the tap Y; the line's Y derives from it (see
|
|
598
|
+
// `lockY`). No-op until the canvas is laid out (price === null).
|
|
599
|
+
const placedPrice = computeValueAtY(
|
|
600
|
+
e.y,
|
|
601
|
+
engine.displayMin.get(),
|
|
602
|
+
engine.displayMax.get(),
|
|
603
|
+
engine.canvasHeight.get(),
|
|
604
|
+
padding.top,
|
|
605
|
+
padding.bottom,
|
|
606
|
+
);
|
|
607
|
+
if (placedPrice === null) return;
|
|
608
|
+
lockPriceValue.set(placedPrice);
|
|
609
|
+
lockActive.set(true);
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const tapGesture = hasScrubAction
|
|
613
|
+
? Gesture.Tap()
|
|
614
|
+
.maxDuration(250)
|
|
615
|
+
.maxDistance(SCRUB_ACTION_TAP_SLOP)
|
|
616
|
+
.onEnd(handleActionTap)
|
|
617
|
+
: undefined;
|
|
618
|
+
|
|
292
619
|
return {
|
|
293
620
|
scrubX,
|
|
294
621
|
scrubActive,
|
|
@@ -298,5 +625,12 @@ export function useCrosshair(
|
|
|
298
625
|
tooltipLayout,
|
|
299
626
|
scrubDotY,
|
|
300
627
|
gesture,
|
|
628
|
+
lockActive,
|
|
629
|
+
lockX,
|
|
630
|
+
lockY,
|
|
631
|
+
lockPrice,
|
|
632
|
+
actionBadge,
|
|
633
|
+
timeBadge,
|
|
634
|
+
tapGesture,
|
|
301
635
|
};
|
|
302
636
|
}
|
package/src/hooks/useMarkers.ts
CHANGED
|
@@ -32,7 +32,12 @@ export function useMarkers(
|
|
|
32
32
|
lineData?: SharedValue<LiveChartPoint[]>,
|
|
33
33
|
/** Static charts run no loops: register without starting. Default `true`. */
|
|
34
34
|
autostart = true,
|
|
35
|
-
): {
|
|
35
|
+
): {
|
|
36
|
+
projected: SharedValue<ProjectedMarker[]>;
|
|
37
|
+
tapGesture: ReturnType<typeof Gesture.Tap>;
|
|
38
|
+
/** Worklet hit-test: true when (x, y) lands on a projected marker. */
|
|
39
|
+
hitTest: (x: number, y: number) => boolean;
|
|
40
|
+
} {
|
|
36
41
|
const projected = useSharedValue<ProjectedMarker[]>([]);
|
|
37
42
|
const cacheRef = useRef<{
|
|
38
43
|
a: ProjectedMarker[];
|
|
@@ -94,5 +99,16 @@ export function useMarkers(
|
|
|
94
99
|
},
|
|
95
100
|
);
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
// Lets a coexisting gesture (e.g. the scrub-action tap) defer to a marker
|
|
103
|
+
// under the finger instead of acting on it.
|
|
104
|
+
const hitTest =
|
|
105
|
+
/* istanbul ignore next -- worklet, runs on the UI thread, not in Jest */ (
|
|
106
|
+
x: number,
|
|
107
|
+
y: number,
|
|
108
|
+
) => {
|
|
109
|
+
"worklet";
|
|
110
|
+
return nearestMarkerIndex(projected.get(), x, y, hitRadius) >= 0;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return { projected, tapGesture, hitTest };
|
|
98
114
|
}
|