react-native-livechart 3.3.0 → 3.5.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 +5 -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/ThresholdLineOverlay.d.ts +34 -0
- package/dist/components/ThresholdLineOverlay.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 +55 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +110 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +5 -1
- package/dist/hooks/useChartPaths.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/useThreshold.d.ts +22 -0
- package/dist/hooks/useThreshold.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/threshold.d.ts +25 -0
- package/dist/math/threshold.d.ts.map +1 -0
- package/dist/types.d.ts +190 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +362 -61
- package/src/components/LiveChartSeries.tsx +39 -2
- package/src/components/ReferenceLineOverlay.tsx +121 -94
- package/src/components/ScrubActionOverlay.tsx +187 -0
- package/src/components/ThresholdLineOverlay.tsx +172 -0
- package/src/components/XAxisOverlay.tsx +2 -1
- package/src/constants.ts +5 -0
- package/src/core/resolveConfig.ts +102 -0
- package/src/hooks/crosshairShared.ts +293 -0
- package/src/hooks/useChartPaths.ts +25 -1
- 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/useThreshold.ts +63 -0
- package/src/index.ts +5 -0
- package/src/math/referenceLines.ts +55 -0
- package/src/math/threshold.ts +59 -0
- package/src/types.ts +195 -6
|
@@ -7,6 +7,7 @@ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
|
7
7
|
import {
|
|
8
8
|
classifyReferenceEdge,
|
|
9
9
|
referenceLineForm,
|
|
10
|
+
resolveReferenceBadge,
|
|
10
11
|
} from "../math/referenceLines";
|
|
11
12
|
import type { ReferenceLine } from "../types";
|
|
12
13
|
|
|
@@ -21,14 +22,30 @@ export interface ReferenceLineLayout {
|
|
|
21
22
|
x1: number;
|
|
22
23
|
/** Right x extent. */
|
|
23
24
|
x2: number;
|
|
24
|
-
/** Resolved label text (with appended value when `showValue`). */
|
|
25
|
+
/** Resolved label text (with appended value when `showValue`); "" when hidden. */
|
|
25
26
|
label: string;
|
|
26
27
|
labelX: number;
|
|
27
28
|
labelY: number;
|
|
28
|
-
/** True when a Form-A
|
|
29
|
+
/** True when a Form-A value is off-screen (badge pinned to the edge + chevron). */
|
|
29
30
|
offAxis: boolean;
|
|
30
31
|
/** Off-axis chevron points up (target above range) vs down (below). */
|
|
31
32
|
chevronUp: boolean;
|
|
33
|
+
/** True when the value renders as a pill badge (icon / text / chevron tag). */
|
|
34
|
+
badge: boolean;
|
|
35
|
+
/** Badge pill rect left x. */
|
|
36
|
+
pillX: number;
|
|
37
|
+
/** Badge pill rect width. */
|
|
38
|
+
pillW: number;
|
|
39
|
+
/** Badge icon glyph x (-1 when no icon). */
|
|
40
|
+
iconX: number;
|
|
41
|
+
/** Badge icon glyph (""/none). */
|
|
42
|
+
icon: string;
|
|
43
|
+
/** Off-axis chevron center x (-1 when no chevron). */
|
|
44
|
+
chevronCx: number;
|
|
45
|
+
/** Connector dashed-line start x (-1 when no connector). */
|
|
46
|
+
connStart: number;
|
|
47
|
+
/** Connector dashed-line end x. */
|
|
48
|
+
connEnd: number;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
const INVISIBLE: ReferenceLineLayout = {
|
|
@@ -42,15 +59,207 @@ const INVISIBLE: ReferenceLineLayout = {
|
|
|
42
59
|
labelY: -1,
|
|
43
60
|
offAxis: false,
|
|
44
61
|
chevronUp: false,
|
|
62
|
+
badge: false,
|
|
63
|
+
pillX: 0,
|
|
64
|
+
pillW: 0,
|
|
65
|
+
iconX: -1,
|
|
66
|
+
icon: "",
|
|
67
|
+
chevronCx: -1,
|
|
68
|
+
connStart: -1,
|
|
69
|
+
connEnd: -1,
|
|
45
70
|
};
|
|
46
71
|
|
|
47
72
|
/** Off-axis indicator inset from the nearest plot edge, in px. */
|
|
48
73
|
const OFF_AXIS_EDGE_INSET = 12;
|
|
49
74
|
|
|
75
|
+
// Badge pill geometry (kept in sync with the overlay's pill height).
|
|
76
|
+
const BADGE_PAD_X = 6;
|
|
77
|
+
const BADGE_GAP = 4;
|
|
78
|
+
const BADGE_CHEV_W = 8;
|
|
79
|
+
/** Pill inset from the pinned plot edge, in px. */
|
|
80
|
+
const BADGE_EDGE_INSET = 2;
|
|
81
|
+
/** Vertical padding inside the pill (matches ReferenceLineOverlay's BADGE_PILL_PAD_Y). */
|
|
82
|
+
const BADGE_PILL_PAD_Y = 3;
|
|
83
|
+
|
|
84
|
+
interface BadgeGeometry {
|
|
85
|
+
pillX: number;
|
|
86
|
+
pillW: number;
|
|
87
|
+
iconX: number;
|
|
88
|
+
textX: number;
|
|
89
|
+
chevronCx: number;
|
|
90
|
+
connStart: number;
|
|
91
|
+
connEnd: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lays out the badge pill at one plot edge: `[chevron?][icon?][text?]` left→right
|
|
96
|
+
* inside the pill, with a dashed connector running to the opposite edge.
|
|
97
|
+
*/
|
|
98
|
+
function badgeGeometry(
|
|
99
|
+
position: "left" | "right",
|
|
100
|
+
icon: string,
|
|
101
|
+
text: string,
|
|
102
|
+
hasChevron: boolean,
|
|
103
|
+
x1: number,
|
|
104
|
+
x2: number,
|
|
105
|
+
font: SkFont,
|
|
106
|
+
): BadgeGeometry {
|
|
107
|
+
"worklet";
|
|
108
|
+
// Measure the icon's visual bounds (not just width): `SkiaText` draws from the
|
|
109
|
+
// pen origin, so the glyph's left side-bearing (`bounds.x`) must be subtracted
|
|
110
|
+
// to land the ink in its slot. Without this an asymmetric glyph (a ▼ triangle,
|
|
111
|
+
// a "+"/"−") sits off-center in an icon-only pill. Mirrors the action-badge
|
|
112
|
+
// price-pill centering in computeActionBadgeLayout.
|
|
113
|
+
const iconBounds = icon ? font.measureText(icon) : null;
|
|
114
|
+
const iconW = iconBounds ? iconBounds.width : 0;
|
|
115
|
+
const textW = text ? measureFontTextWidth(font, text) : 0;
|
|
116
|
+
|
|
117
|
+
let contentW = 0;
|
|
118
|
+
let count = 0;
|
|
119
|
+
if (hasChevron) {
|
|
120
|
+
contentW += BADGE_CHEV_W;
|
|
121
|
+
count++;
|
|
122
|
+
}
|
|
123
|
+
if (icon) {
|
|
124
|
+
contentW += iconW;
|
|
125
|
+
count++;
|
|
126
|
+
}
|
|
127
|
+
if (text) {
|
|
128
|
+
contentW += textW;
|
|
129
|
+
count++;
|
|
130
|
+
}
|
|
131
|
+
contentW += BADGE_GAP * Math.max(0, count - 1);
|
|
132
|
+
|
|
133
|
+
const pillW = contentW + BADGE_PAD_X * 2;
|
|
134
|
+
const pillX =
|
|
135
|
+
position === "right"
|
|
136
|
+
? x2 - BADGE_EDGE_INSET - pillW
|
|
137
|
+
: x1 + BADGE_EDGE_INSET;
|
|
138
|
+
|
|
139
|
+
let cursor = pillX + BADGE_PAD_X;
|
|
140
|
+
let chevronCx = -1;
|
|
141
|
+
if (hasChevron) {
|
|
142
|
+
chevronCx = cursor + BADGE_CHEV_W / 2;
|
|
143
|
+
cursor += BADGE_CHEV_W + BADGE_GAP;
|
|
144
|
+
}
|
|
145
|
+
let iconX = -1;
|
|
146
|
+
if (icon) {
|
|
147
|
+
// Pen origin compensates the left side-bearing so the ink starts at `cursor`
|
|
148
|
+
// (and is centered in an icon-only pill, whose width is iconW + 2*padX).
|
|
149
|
+
iconX = cursor - (iconBounds ? iconBounds.x : 0);
|
|
150
|
+
cursor += iconW + BADGE_GAP;
|
|
151
|
+
}
|
|
152
|
+
const textX = text ? cursor : -1;
|
|
153
|
+
|
|
154
|
+
let connStart = -1;
|
|
155
|
+
let connEnd = -1;
|
|
156
|
+
if (position === "right") {
|
|
157
|
+
const end = pillX - 4;
|
|
158
|
+
if (end > x1) {
|
|
159
|
+
connStart = x1;
|
|
160
|
+
connEnd = end;
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const start = pillX + pillW + 4;
|
|
164
|
+
if (start < x2) {
|
|
165
|
+
connStart = start;
|
|
166
|
+
connEnd = x2;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { pillX, pillW, iconX, textX, chevronCx, connStart, connEnd };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolved badge text for a Form-A line (in-range tag or off-axis pin). */
|
|
174
|
+
function referenceBadgeText(
|
|
175
|
+
line: ReferenceLine,
|
|
176
|
+
badge: ReturnType<typeof resolveReferenceBadge> & object,
|
|
177
|
+
v: number,
|
|
178
|
+
formatValue: (value: number) => string,
|
|
179
|
+
offAxis: boolean,
|
|
180
|
+
): string {
|
|
181
|
+
"worklet";
|
|
182
|
+
if (!badge.showText) return "";
|
|
183
|
+
if (offAxis && badge.legacyText) {
|
|
184
|
+
const word = line.offAxisBadgeLabel ?? line.label;
|
|
185
|
+
return word ? `${word}: ${formatValue(v)}` : formatValue(v);
|
|
186
|
+
}
|
|
187
|
+
if (line.showValue && line.label) return `${line.label} ${formatValue(v)}`;
|
|
188
|
+
return line.label ?? formatValue(v);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Axis-aligned screen rect of a reference-line badge pill (the press hit-target). */
|
|
192
|
+
export interface ReferenceBadgeRect {
|
|
193
|
+
x: number;
|
|
194
|
+
y: number;
|
|
195
|
+
w: number;
|
|
196
|
+
h: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Screen-space pill rect for a Form-A reference line's **badge**, or `null` when
|
|
201
|
+
* the line has no pressable badge (no `badge`/`offAxisBadge`, not a value line, a
|
|
202
|
+
* legacy off-axis badge while in range, or the canvas isn't laid out / range is
|
|
203
|
+
* degenerate). Shares `badgeGeometry` + the edge classification with
|
|
204
|
+
* {@link useReferenceLine}, so the hit-target tracks the rendered pill exactly.
|
|
205
|
+
* Pure worklet — used by {@link useReferenceLinePress} for tap hit-testing.
|
|
206
|
+
*/
|
|
207
|
+
export function computeReferenceBadgeRect(
|
|
208
|
+
line: ReferenceLine,
|
|
209
|
+
canvasWidth: number,
|
|
210
|
+
canvasHeight: number,
|
|
211
|
+
padding: ChartPadding,
|
|
212
|
+
dMin: number,
|
|
213
|
+
dMax: number,
|
|
214
|
+
font: SkFont,
|
|
215
|
+
formatValue: (value: number) => string,
|
|
216
|
+
): ReferenceBadgeRect | null {
|
|
217
|
+
"worklet";
|
|
218
|
+
const badge = resolveReferenceBadge(line);
|
|
219
|
+
if (!badge) return null;
|
|
220
|
+
if (referenceLineForm(line) !== "line" || line.value === undefined) return null;
|
|
221
|
+
if (canvasWidth === 0 || canvasHeight === 0) return null;
|
|
222
|
+
|
|
223
|
+
const chartTop = padding.top;
|
|
224
|
+
const chartBottom = canvasHeight - padding.bottom;
|
|
225
|
+
const chartH = chartBottom - chartTop;
|
|
226
|
+
const valRange = dMax - dMin;
|
|
227
|
+
if (chartH <= 0 || valRange <= 0) return null;
|
|
228
|
+
|
|
229
|
+
const x1 = padding.left;
|
|
230
|
+
const x2 = canvasWidth - padding.right;
|
|
231
|
+
const v = line.value;
|
|
232
|
+
const edge = classifyReferenceEdge(v, dMin, dMax);
|
|
233
|
+
|
|
234
|
+
let y: number;
|
|
235
|
+
let hasChevron: boolean;
|
|
236
|
+
if (edge !== "in") {
|
|
237
|
+
// Off-screen: pinned to the nearest edge with a chevron.
|
|
238
|
+
hasChevron = true;
|
|
239
|
+
y =
|
|
240
|
+
edge === "above"
|
|
241
|
+
? chartTop + OFF_AXIS_EDGE_INSET
|
|
242
|
+
: chartBottom - OFF_AXIS_EDGE_INSET;
|
|
243
|
+
} else {
|
|
244
|
+
// In range: a legacy off-axis-only badge shows no pill here → not pressable.
|
|
245
|
+
if (!badge.inRange) return null;
|
|
246
|
+
hasChevron = false;
|
|
247
|
+
y = chartTop + ((dMax - v) / valRange) * chartH;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const text = referenceBadgeText(line, badge, v, formatValue, edge !== "in");
|
|
251
|
+
const g = badgeGeometry(badge.position, badge.icon, text, hasChevron, x1, x2, font);
|
|
252
|
+
const fm = font.getMetrics();
|
|
253
|
+
const pillH = fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2;
|
|
254
|
+
// The pill is vertically centered on the line's y (see ReferenceLineOverlay).
|
|
255
|
+
return { x: g.pillX, y: y - pillH / 2, w: g.pillW, h: pillH };
|
|
256
|
+
}
|
|
257
|
+
|
|
50
258
|
/**
|
|
51
259
|
* Derives screen-space layout for a single reference line or band. Supports all
|
|
52
260
|
* three `ReferenceLine` forms (horizontal line, horizontal value band, vertical
|
|
53
|
-
* time band) plus the
|
|
261
|
+
* time band) plus the pill badge for a Form-A value (in-range tag + off-screen
|
|
262
|
+
* chevron pin).
|
|
54
263
|
*/
|
|
55
264
|
export function useReferenceLine(
|
|
56
265
|
engine: ChartEngineLayout,
|
|
@@ -60,6 +269,8 @@ export function useReferenceLine(
|
|
|
60
269
|
font: SkFont,
|
|
61
270
|
): SharedValue<ReferenceLineLayout> {
|
|
62
271
|
const form = line ? referenceLineForm(line) : "none";
|
|
272
|
+
// Badge presentation depends only on the (stable) line props — resolve once.
|
|
273
|
+
const badge = line ? resolveReferenceBadge(line) : null;
|
|
63
274
|
|
|
64
275
|
return useDerivedValue<ReferenceLineLayout>(() => {
|
|
65
276
|
if (!line || form === "none") return INVISIBLE;
|
|
@@ -103,6 +314,7 @@ export function useReferenceLine(
|
|
|
103
314
|
? bx2 - 4 - measureFontTextWidth(font, label)
|
|
104
315
|
: bx1 + 4;
|
|
105
316
|
return {
|
|
317
|
+
...INVISIBLE,
|
|
106
318
|
visible: true,
|
|
107
319
|
y: chartTop,
|
|
108
320
|
yBottom: chartBottom,
|
|
@@ -111,8 +323,6 @@ export function useReferenceLine(
|
|
|
111
323
|
label,
|
|
112
324
|
labelX,
|
|
113
325
|
labelY: chartTop - fm.ascent + 2,
|
|
114
|
-
offAxis: false,
|
|
115
|
-
chevronUp: false,
|
|
116
326
|
};
|
|
117
327
|
}
|
|
118
328
|
|
|
@@ -145,6 +355,7 @@ export function useReferenceLine(
|
|
|
145
355
|
? x2 - 4 - measureFontTextWidth(font, label)
|
|
146
356
|
: x1 + 4;
|
|
147
357
|
return {
|
|
358
|
+
...INVISIBLE,
|
|
148
359
|
visible: true,
|
|
149
360
|
y: yTop,
|
|
150
361
|
yBottom: yBot,
|
|
@@ -153,39 +364,74 @@ export function useReferenceLine(
|
|
|
153
364
|
label,
|
|
154
365
|
labelX,
|
|
155
366
|
labelY: yTop - fm.ascent + 2,
|
|
156
|
-
offAxis: false,
|
|
157
|
-
chevronUp: false,
|
|
158
367
|
};
|
|
159
368
|
}
|
|
160
369
|
|
|
161
|
-
// ── Form A — horizontal line (with optional
|
|
370
|
+
// ── Form A — horizontal line (with optional pill badge) ──────────────────
|
|
162
371
|
if (line.value === undefined) return INVISIBLE;
|
|
163
372
|
const v = line.value;
|
|
164
373
|
const edge = classifyReferenceEdge(v, dMin, dMax);
|
|
165
374
|
|
|
375
|
+
// Off-screen: pin the badge to the nearest edge with a chevron (when a badge
|
|
376
|
+
// is configured); otherwise cull the off-screen line (legacy behavior).
|
|
166
377
|
if (edge !== "in") {
|
|
167
|
-
if (!
|
|
378
|
+
if (!badge) return INVISIBLE;
|
|
168
379
|
const above = edge === "above";
|
|
169
380
|
const clampedY = above
|
|
170
381
|
? chartTop + OFF_AXIS_EDGE_INSET
|
|
171
382
|
: chartBottom - OFF_AXIS_EDGE_INSET;
|
|
172
|
-
const
|
|
173
|
-
const
|
|
383
|
+
const text = referenceBadgeText(line, badge, v, formatValue, true);
|
|
384
|
+
const g = badgeGeometry(badge.position, badge.icon, text, true, x1, x2, font);
|
|
174
385
|
return {
|
|
386
|
+
...INVISIBLE,
|
|
175
387
|
visible: true,
|
|
176
388
|
y: clampedY,
|
|
177
389
|
yBottom: clampedY,
|
|
178
390
|
x1,
|
|
179
391
|
x2,
|
|
180
392
|
label: text,
|
|
181
|
-
labelX:
|
|
393
|
+
labelX: g.textX,
|
|
182
394
|
labelY: clampedY - baselineOffset,
|
|
183
395
|
offAxis: true,
|
|
184
396
|
chevronUp: above,
|
|
397
|
+
badge: true,
|
|
398
|
+
pillX: g.pillX,
|
|
399
|
+
pillW: g.pillW,
|
|
400
|
+
iconX: g.iconX,
|
|
401
|
+
icon: badge.icon,
|
|
402
|
+
chevronCx: g.chevronCx,
|
|
403
|
+
connStart: g.connStart,
|
|
404
|
+
connEnd: g.connEnd,
|
|
185
405
|
};
|
|
186
406
|
}
|
|
187
407
|
|
|
188
408
|
const y = toY(v);
|
|
409
|
+
|
|
410
|
+
// In-range pill badge (the `badge` config, not the legacy off-axis-only flag).
|
|
411
|
+
if (badge && badge.inRange) {
|
|
412
|
+
const text = referenceBadgeText(line, badge, v, formatValue, false);
|
|
413
|
+
const g = badgeGeometry(badge.position, badge.icon, text, false, x1, x2, font);
|
|
414
|
+
return {
|
|
415
|
+
...INVISIBLE,
|
|
416
|
+
visible: true,
|
|
417
|
+
y,
|
|
418
|
+
yBottom: y,
|
|
419
|
+
x1,
|
|
420
|
+
x2,
|
|
421
|
+
label: text,
|
|
422
|
+
labelX: g.textX,
|
|
423
|
+
labelY: y - baselineOffset,
|
|
424
|
+
badge: true,
|
|
425
|
+
pillX: g.pillX,
|
|
426
|
+
pillW: g.pillW,
|
|
427
|
+
iconX: g.iconX,
|
|
428
|
+
icon: badge.icon,
|
|
429
|
+
connStart: g.connStart,
|
|
430
|
+
connEnd: g.connEnd,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Plain gutter label (no badge, or a legacy off-axis badge that's in range).
|
|
189
435
|
let label = line.label ?? formatValue(v);
|
|
190
436
|
if (line.showValue && line.label) label = `${line.label} ${formatValue(v)}`;
|
|
191
437
|
|
|
@@ -197,6 +443,7 @@ export function useReferenceLine(
|
|
|
197
443
|
else labelX = x2 + 4; // "right" — legacy gutter position
|
|
198
444
|
|
|
199
445
|
return {
|
|
446
|
+
...INVISIBLE,
|
|
200
447
|
visible: true,
|
|
201
448
|
y,
|
|
202
449
|
yBottom: y,
|
|
@@ -205,8 +452,6 @@ export function useReferenceLine(
|
|
|
205
452
|
label,
|
|
206
453
|
labelX,
|
|
207
454
|
labelY: y - baselineOffset,
|
|
208
|
-
offAxis: false,
|
|
209
|
-
chevronUp: false,
|
|
210
455
|
};
|
|
211
456
|
});
|
|
212
457
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { SkFont } from "@shopify/react-native-skia";
|
|
2
|
+
import { Gesture } from "react-native-gesture-handler";
|
|
3
|
+
import { useDerivedValue } from "react-native-reanimated";
|
|
4
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
5
|
+
|
|
6
|
+
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
7
|
+
import type { ChartPadding } from "../draw/line";
|
|
8
|
+
import type { ReferenceLine } from "../types";
|
|
9
|
+
import { pointInRect } from "./crosshairShared";
|
|
10
|
+
import {
|
|
11
|
+
computeReferenceBadgeRect,
|
|
12
|
+
type ReferenceBadgeRect,
|
|
13
|
+
} from "./useReferenceLine";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds a tap gesture that hit-tests a chart's reference-line **badges** (the
|
|
17
|
+
* pill tags for working orders / alerts / targets) and fires `onPress(line,
|
|
18
|
+
* index)` when one is tapped. Mirrors {@link useMarkers}: badge rects are
|
|
19
|
+
* projected to screen each frame on the UI thread (so they track the rescaling
|
|
20
|
+
* axis exactly like the rendered pills), and the gesture hit-tests against them.
|
|
21
|
+
*
|
|
22
|
+
* Also returns a `hitTest` worklet so a coexisting gesture (the scrub-action tap)
|
|
23
|
+
* can defer to a badge under the finger instead of acting on it. Only badge-tagged
|
|
24
|
+
* Form-A (value) lines are pressable; everything else projects to `null`.
|
|
25
|
+
*/
|
|
26
|
+
export function useReferenceLinePress(
|
|
27
|
+
engine: ChartEngineLayout,
|
|
28
|
+
padding: ChartPadding,
|
|
29
|
+
lines: ReferenceLine[],
|
|
30
|
+
font: SkFont,
|
|
31
|
+
formatValue: (v: number) => string,
|
|
32
|
+
active: boolean,
|
|
33
|
+
/** Touch-target inflation around each pill, in px. */
|
|
34
|
+
hitSlop: number,
|
|
35
|
+
onPress?: (line: ReferenceLine, index: number) => void,
|
|
36
|
+
): {
|
|
37
|
+
tapGesture: ReturnType<typeof Gesture.Tap>;
|
|
38
|
+
hitTest: (x: number, y: number) => boolean;
|
|
39
|
+
} {
|
|
40
|
+
// Per-frame badge hit-rects, index-aligned with `lines` (null = no pressable
|
|
41
|
+
// badge / off-screen / not laid out). Recomputed on the UI thread.
|
|
42
|
+
/* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
|
|
43
|
+
const rects = useDerivedValue<(ReferenceBadgeRect | null)[]>(() => {
|
|
44
|
+
if (!active || lines.length === 0) return [];
|
|
45
|
+
const w = engine.canvasWidth.value;
|
|
46
|
+
const h = engine.canvasHeight.value;
|
|
47
|
+
const dMin = engine.displayMin.value;
|
|
48
|
+
const dMax = engine.displayMax.value;
|
|
49
|
+
const out: (ReferenceBadgeRect | null)[] = [];
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
out.push(
|
|
52
|
+
computeReferenceBadgeRect(lines[i], w, h, padding, dMin, dMax, font, formatValue),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Topmost badge under (x, y), or -1. Last drawn = last in the array = topmost.
|
|
59
|
+
/* istanbul ignore next -- worklet, runs on the UI thread */
|
|
60
|
+
const indexAt = (x: number, y: number): number => {
|
|
61
|
+
"worklet";
|
|
62
|
+
const rs = rects.value;
|
|
63
|
+
for (let i = rs.length - 1; i >= 0; i--) {
|
|
64
|
+
const r = rs[i];
|
|
65
|
+
if (r && pointInRect(x, y, r, hitSlop)) return i;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/* istanbul ignore next -- worklet, runs on the UI thread */
|
|
71
|
+
const hitTest = (x: number, y: number): boolean => {
|
|
72
|
+
"worklet";
|
|
73
|
+
return indexAt(x, y) >= 0;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/* istanbul ignore next -- runs only via scheduleOnRN from the UI-thread tap */
|
|
77
|
+
function emitPress(index: number) {
|
|
78
|
+
onPress?.(lines[index], index);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
|
|
82
|
+
const tapGesture = Gesture.Tap()
|
|
83
|
+
.maxDuration(250)
|
|
84
|
+
.onEnd((e, success) => {
|
|
85
|
+
"worklet";
|
|
86
|
+
if (!active || !success) return;
|
|
87
|
+
const i = indexAt(e.x, e.y);
|
|
88
|
+
if (i >= 0) scheduleOnRN(emitPress, i);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return { tapGesture, hitTest };
|
|
92
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { vec } from "@shopify/react-native-skia";
|
|
2
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
3
|
+
|
|
4
|
+
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
5
|
+
import type { ChartPadding } from "../draw/line";
|
|
6
|
+
import {
|
|
7
|
+
thresholdLineY,
|
|
8
|
+
thresholdSplitPositions,
|
|
9
|
+
thresholdVisible,
|
|
10
|
+
} from "../math/threshold";
|
|
11
|
+
|
|
12
|
+
export interface ThresholdGeometry {
|
|
13
|
+
/** Threshold pixel-Y within the canvas, or NaN before layout / degenerate range. */
|
|
14
|
+
lineY: SharedValue<number>;
|
|
15
|
+
/** Whether the threshold sits within the plot area (drives marker-line opacity). */
|
|
16
|
+
visible: SharedValue<boolean>;
|
|
17
|
+
/** Vertical gradient end vector, `vec(0, canvasHeight)` — shared by stroke + fill. */
|
|
18
|
+
gradientEnd: SharedValue<ReturnType<typeof vec>>;
|
|
19
|
+
/** `[0, t, t, 1]` stop positions for the hard-split gradient — shared by stroke + fill. */
|
|
20
|
+
splitPositions: SharedValue<number[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-frame screen geometry for the threshold split, shared by the stroke
|
|
25
|
+
* gradient, the profit/loss fill band, and the dashed marker line. Reads the live
|
|
26
|
+
* threshold value + engine Y-range on the UI thread; the math lives in
|
|
27
|
+
* `math/threshold` so it stays unit-testable without Reanimated.
|
|
28
|
+
*/
|
|
29
|
+
export function useThreshold(
|
|
30
|
+
engine: ChartEngineLayout,
|
|
31
|
+
padding: ChartPadding,
|
|
32
|
+
value: SharedValue<number>,
|
|
33
|
+
): ThresholdGeometry {
|
|
34
|
+
const lineY = useDerivedValue(() =>
|
|
35
|
+
thresholdLineY(
|
|
36
|
+
value.get(),
|
|
37
|
+
engine.displayMin.get(),
|
|
38
|
+
engine.displayMax.get(),
|
|
39
|
+
engine.canvasHeight.get(),
|
|
40
|
+
padding.top,
|
|
41
|
+
padding.bottom,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const visible = useDerivedValue(() =>
|
|
46
|
+
thresholdVisible(
|
|
47
|
+
lineY.get(),
|
|
48
|
+
engine.canvasHeight.get(),
|
|
49
|
+
padding.top,
|
|
50
|
+
padding.bottom,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const splitPositions = useDerivedValue(() =>
|
|
55
|
+
thresholdSplitPositions(lineY.get(), engine.canvasHeight.get()),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const gradientEnd = useDerivedValue(() =>
|
|
59
|
+
vec(0, Math.max(1, engine.canvasHeight.get())),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return { lineY, visible, gradientEnd, splitPositions };
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,9 @@ export type {
|
|
|
64
64
|
MultiSeriesDotConfig,
|
|
65
65
|
PulseConfig,
|
|
66
66
|
ReferenceLine,
|
|
67
|
+
ReferenceLineBadgeConfig,
|
|
68
|
+
ScrubActionConfig,
|
|
69
|
+
ScrubActionPoint,
|
|
67
70
|
ScrubConfig,
|
|
68
71
|
ScrubPoint,
|
|
69
72
|
ScrubPointCore,
|
|
@@ -74,6 +77,8 @@ export type {
|
|
|
74
77
|
SelectionDotRingConfig,
|
|
75
78
|
SeriesConfig,
|
|
76
79
|
ThemeMode,
|
|
80
|
+
ThresholdConfig,
|
|
81
|
+
ThresholdLineConfig,
|
|
77
82
|
TradeEvent,
|
|
78
83
|
ValueLineConfig,
|
|
79
84
|
XAxisConfig,
|
|
@@ -40,6 +40,61 @@ export function collectReferenceValues(lines: ReferenceLine[]): number[] {
|
|
|
40
40
|
return out;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Fully-resolved badge presentation for a Form-A reference line. */
|
|
44
|
+
export interface ResolvedReferenceBadge {
|
|
45
|
+
position: "left" | "right";
|
|
46
|
+
/** Leading glyph, or "" for none. */
|
|
47
|
+
icon: string;
|
|
48
|
+
/** Whether the text label is shown. */
|
|
49
|
+
showText: boolean;
|
|
50
|
+
/** undefined → theme tooltipBg at render time. */
|
|
51
|
+
background: string | undefined;
|
|
52
|
+
/** undefined → the line color at render time. */
|
|
53
|
+
borderColor: string | undefined;
|
|
54
|
+
radius: number;
|
|
55
|
+
/** Show the pill at the value when in range (`badge`) vs only off-screen (legacy `offAxisBadge`). */
|
|
56
|
+
inRange: boolean;
|
|
57
|
+
/** Legacy `"word: value"` text format (off-axis badge) vs the `label`/value format. */
|
|
58
|
+
legacyText: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a Form-A reference line's badge presentation from the new `badge`
|
|
63
|
+
* config or the legacy `offAxisBadge` flag (the `badge` config wins). Returns
|
|
64
|
+
* null when neither is set. Pure — driven only by the line props.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveReferenceBadge(
|
|
67
|
+
rl: ReferenceLine,
|
|
68
|
+
): ResolvedReferenceBadge | null {
|
|
69
|
+
"worklet";
|
|
70
|
+
if (rl.badge) {
|
|
71
|
+
const cfg = rl.badge === true ? undefined : rl.badge;
|
|
72
|
+
return {
|
|
73
|
+
position: cfg?.position ?? "left",
|
|
74
|
+
icon: cfg?.icon ?? "",
|
|
75
|
+
showText: cfg?.text ?? true,
|
|
76
|
+
background: cfg?.background ?? rl.badgeBackground,
|
|
77
|
+
borderColor: cfg?.borderColor ?? rl.badgeBorderColor,
|
|
78
|
+
radius: cfg?.radius ?? rl.badgeRadius ?? 5,
|
|
79
|
+
inRange: true,
|
|
80
|
+
legacyText: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (rl.offAxisBadge) {
|
|
84
|
+
return {
|
|
85
|
+
position: "left",
|
|
86
|
+
icon: "",
|
|
87
|
+
showText: true,
|
|
88
|
+
background: rl.badgeBackground,
|
|
89
|
+
borderColor: rl.badgeBorderColor,
|
|
90
|
+
radius: rl.badgeRadius ?? 5,
|
|
91
|
+
inRange: false,
|
|
92
|
+
legacyText: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
/**
|
|
44
99
|
* Where a Y value sits relative to the visible plot range:
|
|
45
100
|
* `"in"` (within [min, max]), `"above"` (greater than max), or `"below"`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, worklet-safe geometry for the threshold split ({@link ThresholdConfig}).
|
|
3
|
+
* No SharedValues and no hooks — these are unit-tested directly; `useThreshold`
|
|
4
|
+
* wires the live SharedValues into them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pixel Y of a threshold value within the plot. Mirrors the line path's value→Y
|
|
9
|
+
* mapping in `buildLinePoints` (`top + (max - v) / range * chartH`). Returns NaN
|
|
10
|
+
* when the canvas hasn't laid out yet or the value range is degenerate, so the
|
|
11
|
+
* line/fill/marker callers can cull instead of drawing at a bogus position.
|
|
12
|
+
*/
|
|
13
|
+
export function thresholdLineY(
|
|
14
|
+
value: number,
|
|
15
|
+
displayMin: number,
|
|
16
|
+
displayMax: number,
|
|
17
|
+
canvasHeight: number,
|
|
18
|
+
paddingTop: number,
|
|
19
|
+
paddingBottom: number,
|
|
20
|
+
): number {
|
|
21
|
+
"worklet";
|
|
22
|
+
const range = displayMax - displayMin;
|
|
23
|
+
const chartH = canvasHeight - paddingTop - paddingBottom;
|
|
24
|
+
if (canvasHeight <= 0 || range <= 0 || chartH <= 0) return NaN;
|
|
25
|
+
return paddingTop + ((displayMax - value) / range) * chartH;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when the threshold's Y sits within the plot area (i.e. on-screen). */
|
|
29
|
+
export function thresholdVisible(
|
|
30
|
+
lineY: number,
|
|
31
|
+
canvasHeight: number,
|
|
32
|
+
paddingTop: number,
|
|
33
|
+
paddingBottom: number,
|
|
34
|
+
): boolean {
|
|
35
|
+
"worklet";
|
|
36
|
+
if (!Number.isFinite(lineY) || canvasHeight <= 0) return false;
|
|
37
|
+
return lineY >= paddingTop && lineY <= canvasHeight - paddingBottom;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stop positions for the vertical hard-split gradient, paired with the 4-stop
|
|
42
|
+
* color array `[above, above, below, below]`: `[0, t, t, 1]`, where `t` is the
|
|
43
|
+
* threshold's fraction down the full canvas (the gradient vector spans
|
|
44
|
+
* `0 → canvasHeight`), clamped to `[0, 1]`.
|
|
45
|
+
*
|
|
46
|
+
* - `t ≤ 0` (threshold above everything) → `[0, 0, 0, 1]` → solid below-color.
|
|
47
|
+
* - `t ≥ 1` (threshold below everything) → `[0, 1, 1, 1]` → solid above-color.
|
|
48
|
+
*/
|
|
49
|
+
export function thresholdSplitPositions(
|
|
50
|
+
lineY: number,
|
|
51
|
+
canvasHeight: number,
|
|
52
|
+
): number[] {
|
|
53
|
+
"worklet";
|
|
54
|
+
if (canvasHeight <= 0 || !Number.isFinite(lineY)) return [0, 1, 1, 1];
|
|
55
|
+
let t = lineY / canvasHeight;
|
|
56
|
+
if (t < 0) t = 0;
|
|
57
|
+
else if (t > 1) t = 1;
|
|
58
|
+
return [0, t, t, 1];
|
|
59
|
+
}
|