react-native-livechart 4.9.1 → 4.9.2

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.
@@ -3,7 +3,6 @@ import { Platform } from "react-native";
3
3
  import { Gesture } from "react-native-gesture-handler";
4
4
  import {
5
5
  useAnimatedReaction,
6
- useDerivedValue,
7
6
  useSharedValue,
8
7
  type SharedValue,
9
8
  } from "react-native-reanimated";
@@ -32,6 +31,7 @@ import {
32
31
  deriveCrosshairTooltipSingle,
33
32
  HIDDEN_ACTION_BADGE,
34
33
  HIDDEN_TIME_BADGE,
34
+ HIDDEN_TOOLTIP,
35
35
  pointInRect,
36
36
  snapPrice,
37
37
  type CrosshairState,
@@ -172,203 +172,216 @@ export function useCrosshair(
172
172
  const actionShowText = scrubAction?.text ?? true;
173
173
  const hasTimeBadge = scrubAction?.timeBadge ?? false;
174
174
 
175
- const scrubTime = useDerivedValue(() =>
176
- computeScrubTime(
177
- scrubActive.get(),
178
- scrubX.get(),
179
- padding,
180
- engine.canvasWidth.get(),
181
- engine.timestamp.get(),
182
- engine.displayWindow.get(),
183
- ),
184
- );
185
-
186
175
  const isCandleMode = candleOpts?.mode === "candle";
187
176
  const candlesSV = candleOpts?.candles;
188
177
  const liveCandleSV = candleOpts?.liveCandle;
189
178
  const candleWidthSecs = candleOpts?.candleWidthSecs ?? 60;
190
179
 
191
- /* istanbul ignore next -- worklet */
192
- const scrubCandle = useDerivedValue(() => {
193
- if (
194
- !isCandleMode ||
195
- !candlesSV ||
196
- !scrubActive.get() ||
197
- scrubTime.get() < 0
198
- )
199
- return null;
200
- return pickCandleAtTime(
201
- candlesSV.get(),
202
- liveCandleSV?.get() ?? null,
203
- scrubTime.get(),
204
- candleWidthSecs,
205
- );
206
- });
207
-
208
- /* istanbul ignore next -- worklet */
209
- const scrubValue = useDerivedValue(() => {
210
- if (!scrubActive.get() || scrubTime.get() < 0) return null;
211
- if (isCandleMode) {
212
- return scrubCandle.get()?.close ?? null;
213
- }
214
- return interpolateAtTime(engine.data.get(), scrubTime.get());
215
- });
216
-
217
- const crosshairOpacity = useDerivedValue(() =>
218
- computeCrosshairOpacity(
219
- scrubActive.get(),
220
- scrubX.get(),
221
- engine.canvasWidth.get(),
222
- padding.right,
223
- ),
224
- );
225
-
226
- // Y pixel of the scrub intersection — used by the selection dot. -1 when
227
- // there's no value to mark.
228
- const scrubDotY = useDerivedValue(() =>
229
- computeScrubDotY(
230
- scrubValue.get(),
231
- engine.displayMin.get(),
232
- engine.displayMax.get(),
233
- engine.canvasHeight.get(),
234
- padding.top,
235
- padding.bottom,
236
- ),
237
- );
238
-
239
180
  // Monospace advance width, measured once per render (not per scrub frame) so
240
181
  // the tooltip layout worklet can size text by character count instead of a
241
182
  // per-frame Skia measureText.
242
183
  const monoCharWidth = font.measureText("0").width;
243
-
244
- const tooltipLayout = useDerivedValue(() => {
245
- if (isCandleMode) {
246
- return computeCandleTooltipLayout(
247
- scrubActive.get(),
248
- scrubX.get(),
249
- scrubCandle.get(),
250
- scrubTime.get(),
184
+ const scrubTime = useSharedValue(-1);
185
+ const scrubCandle = useSharedValue<CandlePoint | null>(null);
186
+ const scrubValue = useSharedValue<number | null>(null);
187
+ const crosshairOpacity = useSharedValue(0);
188
+ const scrubDotY = useSharedValue(-1);
189
+ const tooltipLayout = useSharedValue(HIDDEN_TOOLTIP);
190
+
191
+ // These six outputs traverse the same scrub + engine inputs. One reaction
192
+ // computes and publishes the snapshot atomically instead of compiling six
193
+ // independent derived-value mappers with chained dependencies.
194
+ useAnimatedReaction(
195
+ () => {
196
+ "worklet";
197
+ const active = scrubActive.get();
198
+ const x = scrubX.get();
199
+ const canvasWidth = engine.canvasWidth.get();
200
+ const canvasHeight = engine.canvasHeight.get();
201
+ const time = computeScrubTime(
202
+ active,
203
+ x,
251
204
  padding,
252
- engine.canvasWidth.get(),
253
- formatValue,
254
- formatTime,
255
- font,
256
- monoCharWidth,
205
+ canvasWidth,
206
+ engine.timestamp.get(),
207
+ engine.displayWindow.get(),
257
208
  );
258
- }
259
- return deriveCrosshairTooltipSingle(
260
- scrubActive.get(),
261
- scrubX.get(),
262
- scrubTime.get(),
263
- scrubValue.get(),
264
- padding,
265
- engine.canvasWidth.get(),
266
- formatValue,
267
- formatTime,
268
- font,
269
- monoCharWidth,
270
- tooltipPlacement,
271
- tooltipShowValue,
272
- tooltipShowTime,
273
- engine.canvasHeight.get(),
274
- tooltipMargin,
275
- scrubDotY.get(),
276
- );
277
- });
209
+ const candle =
210
+ isCandleMode && candlesSV && active && time >= 0
211
+ ? pickCandleAtTime(
212
+ candlesSV.get(),
213
+ liveCandleSV?.get() ?? null,
214
+ time,
215
+ candleWidthSecs,
216
+ )
217
+ : null;
218
+ const value =
219
+ !active || time < 0
220
+ ? null
221
+ : isCandleMode
222
+ ? (candle?.close ?? null)
223
+ : interpolateAtTime(engine.data.get(), time);
224
+ const dotY = computeScrubDotY(
225
+ value,
226
+ engine.displayMin.get(),
227
+ engine.displayMax.get(),
228
+ canvasHeight,
229
+ padding.top,
230
+ padding.bottom,
231
+ );
232
+ const layout = isCandleMode
233
+ ? computeCandleTooltipLayout(
234
+ active,
235
+ x,
236
+ candle,
237
+ time,
238
+ padding,
239
+ canvasWidth,
240
+ formatValue,
241
+ formatTime,
242
+ font,
243
+ monoCharWidth,
244
+ )
245
+ : deriveCrosshairTooltipSingle(
246
+ active,
247
+ x,
248
+ time,
249
+ value,
250
+ padding,
251
+ canvasWidth,
252
+ formatValue,
253
+ formatTime,
254
+ font,
255
+ monoCharWidth,
256
+ tooltipPlacement,
257
+ tooltipShowValue,
258
+ tooltipShowTime,
259
+ canvasHeight,
260
+ tooltipMargin,
261
+ dotY,
262
+ );
263
+ return {
264
+ time,
265
+ candle,
266
+ value,
267
+ opacity: computeCrosshairOpacity(active, x, canvasWidth, padding.right),
268
+ dotY,
269
+ layout,
270
+ };
271
+ },
272
+ (snapshot) => {
273
+ "worklet";
274
+ scrubTime.set(snapshot.time);
275
+ scrubCandle.set(snapshot.candle);
276
+ scrubValue.set(snapshot.value);
277
+ crosshairOpacity.set(snapshot.opacity);
278
+ scrubDotY.set(snapshot.dotY);
279
+ tooltipLayout.set(snapshot.layout);
280
+ },
281
+ );
278
282
 
279
283
  // ── Scrub-action lock derivations ──────────────────────────────────────────
280
- // Reported price = the frozen chosen price, optionally snapped. Independent of
281
- // the display range (the point of freezing it), so the badge readout and the
282
- // onScrubAction payload stay stable while the chart auto-rescales. Null until a
283
- // reticle is placed.
284
- /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
285
- const lockPrice = useDerivedValue(() => {
286
- if (!lockActive.get()) return null;
287
- const p = lockPriceValue.get();
288
- return p === null ? null : snapPrice(p, snapIncrement);
289
- });
290
-
291
- // Screen Y of the locked level, DERIVED from the frozen price each frame so the
292
- // line + badge track the chosen price as displayMin/Max move (rather than the
293
- // price drifting under a pixel-fixed reticle). Pins to the plot edge when the
294
- // price scrolls out of the visible range; -1 until placed / laid out.
284
+ const lockPrice = useSharedValue<number | null>(null);
285
+ const lockY = useSharedValue(-1);
286
+ const lockTime = useSharedValue(-1);
287
+ const lockCandle = useSharedValue<CandlePoint | null>(null);
288
+ const actionBadge = useSharedValue(HIDDEN_ACTION_BADGE);
289
+ const timeBadge = useSharedValue(HIDDEN_TIME_BADGE);
290
+
291
+ // The lock price, projected coordinates, candle and both badges are one
292
+ // coherent reticle snapshot. Publishing them from one reaction replaces six
293
+ // chained derived-value mappers and prevents intermediate mixed-frame state.
295
294
  /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
296
- const lockY = useDerivedValue(() => {
297
- const p = lockPriceValue.get();
298
- const ch = engine.canvasHeight.get();
299
- if (p === null || ch - padding.top - padding.bottom <= 0) return -1;
300
- const y = computeScrubDotY(
301
- p,
302
- engine.displayMin.get(),
303
- engine.displayMax.get(),
304
- ch,
305
- padding.top,
306
- padding.bottom,
307
- );
308
- return clampPlotY(y, padding.top, ch, padding.bottom);
309
- });
310
-
311
- const lockTime = useDerivedValue(() =>
312
- computeScrubTime(
313
- lockActive.get(),
314
- lockX.get(),
315
- padding,
316
- engine.canvasWidth.get(),
317
- engine.timestamp.get(),
318
- engine.displayWindow.get(),
319
- ),
295
+ useAnimatedReaction(
296
+ () => {
297
+ "worklet";
298
+ const active = lockActive.get();
299
+ const x = lockX.get();
300
+ const rawPrice = lockPriceValue.get();
301
+ const canvasWidth = engine.canvasWidth.get();
302
+ const canvasHeight = engine.canvasHeight.get();
303
+ const price =
304
+ active && rawPrice !== null
305
+ ? snapPrice(rawPrice, snapIncrement)
306
+ : null;
307
+ let y = -1;
308
+ if (
309
+ rawPrice !== null &&
310
+ canvasHeight - padding.top - padding.bottom > 0
311
+ ) {
312
+ y = clampPlotY(
313
+ computeScrubDotY(
314
+ rawPrice,
315
+ engine.displayMin.get(),
316
+ engine.displayMax.get(),
317
+ canvasHeight,
318
+ padding.top,
319
+ padding.bottom,
320
+ ),
321
+ padding.top,
322
+ canvasHeight,
323
+ padding.bottom,
324
+ );
325
+ }
326
+ const time = computeScrubTime(
327
+ active,
328
+ x,
329
+ padding,
330
+ canvasWidth,
331
+ engine.timestamp.get(),
332
+ engine.displayWindow.get(),
333
+ );
334
+ const candle =
335
+ isCandleMode && candlesSV && active && time >= 0
336
+ ? pickCandleAtTime(
337
+ candlesSV.get(),
338
+ liveCandleSV?.get() ?? null,
339
+ time,
340
+ candleWidthSecs,
341
+ )
342
+ : null;
343
+ const badge =
344
+ price === null
345
+ ? HIDDEN_ACTION_BADGE
346
+ : computeActionBadgeLayout(
347
+ active,
348
+ y,
349
+ actionShowText ? formatValue(price) : "",
350
+ actionIcon,
351
+ canvasWidth,
352
+ canvasWidth - padding.right,
353
+ font,
354
+ badgeMetrics.marginEdge,
355
+ badgeMetrics.padX,
356
+ badgeMetrics.padY,
357
+ );
358
+ const timeLayout =
359
+ !hasTimeBadge || !active || time < 0
360
+ ? HIDDEN_TIME_BADGE
361
+ : computeTimeBadgeLayout(
362
+ active,
363
+ x,
364
+ formatTime(time),
365
+ canvasWidth,
366
+ canvasHeight - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
367
+ font,
368
+ badgeMetrics.padX,
369
+ badgeMetrics.padY,
370
+ badgeMetrics.marginEdge,
371
+ );
372
+ return { price, y, time, candle, badge, timeLayout };
373
+ },
374
+ (snapshot) => {
375
+ "worklet";
376
+ lockPrice.set(snapshot.price);
377
+ lockY.set(snapshot.y);
378
+ lockTime.set(snapshot.time);
379
+ lockCandle.set(snapshot.candle);
380
+ actionBadge.set(snapshot.badge);
381
+ timeBadge.set(snapshot.timeLayout);
382
+ },
320
383
  );
321
384
 
322
- /* istanbul ignore next -- worklet */
323
- const lockCandle = useDerivedValue(() => {
324
- if (!isCandleMode || !candlesSV || !lockActive.get() || lockTime.get() < 0)
325
- return null;
326
- return pickCandleAtTime(
327
- candlesSV.get(),
328
- liveCandleSV?.get() ?? null,
329
- lockTime.get(),
330
- candleWidthSecs,
331
- );
332
- });
333
-
334
- // Right-gutter action-badge layout — also the tap hit rect. Hidden when not locked.
335
- /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
336
- const actionBadge = useDerivedValue(() => {
337
- const price = lockPrice.get();
338
- if (price === null) return HIDDEN_ACTION_BADGE;
339
- return computeActionBadgeLayout(
340
- lockActive.get(),
341
- lockY.get(),
342
- actionShowText ? formatValue(price) : "",
343
- actionIcon,
344
- engine.canvasWidth.get(),
345
- engine.canvasWidth.get() - padding.right,
346
- font,
347
- badgeMetrics.marginEdge,
348
- badgeMetrics.padX,
349
- badgeMetrics.padY,
350
- );
351
- });
352
-
353
- // X-axis time badge at the reticle (opt-in). Hidden unless enabled + locked.
354
- /* istanbul ignore next -- worklet (locked branch only runs on the UI thread) */
355
- const timeBadge = useDerivedValue(() => {
356
- if (!hasTimeBadge || !lockActive.get()) return HIDDEN_TIME_BADGE;
357
- const t = lockTime.get();
358
- if (t < 0) return HIDDEN_TIME_BADGE;
359
- return computeTimeBadgeLayout(
360
- lockActive.get(),
361
- lockX.get(),
362
- formatTime(t),
363
- engine.canvasWidth.get(),
364
- engine.canvasHeight.get() - padding.bottom + X_AXIS_LABEL_OFFSET_Y,
365
- font,
366
- badgeMetrics.padX,
367
- badgeMetrics.padY,
368
- badgeMetrics.marginEdge,
369
- );
370
- });
371
-
372
385
  /* istanbul ignore next -- invoked only via scheduleOnRN from UI-thread gesture */
373
386
  function handleScrubAction(
374
387
  price: number,
@@ -129,15 +129,28 @@ export function useXAxis(
129
129
  targetKeys.push(Math.round(t * 100));
130
130
  }
131
131
 
132
- const alphas = labelAlphas.get();
132
+ // SharedValue payloads may be frozen after crossing the JS/UI boundary.
133
+ // Work on a fresh cache (including fresh entries) so adding/removing keys
134
+ // and updating alpha never mutates the serialized value returned by get().
135
+ const previousAlphas = labelAlphas.get();
136
+ const alphas: Record<number, { alpha: number; text: string }> = {};
137
+ const previousKeys = Object.keys(previousAlphas);
138
+ for (let i = 0; i < previousKeys.length; i++) {
139
+ const key = Number(previousKeys[i]);
140
+ const previous = previousAlphas[key];
141
+ alphas[key] = { alpha: previous.alpha, text: previous.text };
142
+ }
143
+ let cacheChanged = false;
133
144
 
134
145
  // Create labels for new keys only. A key encodes its time, so the formatted
135
- // text never changes — re-formatting (and re-allocating the entry) every
136
- // frame just churns strings/objects for the GC.
146
+ // text never changes — re-formatting every frame would churn strings for
147
+ // the GC. The cache itself is copied below to keep SharedValue payloads
148
+ // immutable after they cross the JS/UI boundary.
137
149
  for (let i = 0; i < targetKeys.length; i++) {
138
150
  const key = targetKeys[i];
139
151
  if (!alphas[key]) {
140
152
  alphas[key] = { alpha: 0, text: formatTime(key / 100) };
153
+ cacheChanged = true;
141
154
  }
142
155
  }
143
156
 
@@ -160,13 +173,16 @@ export function useXAxis(
160
173
 
161
174
  if (next < 0.01 && target === 0) {
162
175
  delete alphas[key];
176
+ cacheChanged = true;
163
177
  } else {
164
- // Mutate in place text is stable, so no need to reallocate the entry.
178
+ // The entry is already a private copy, so updating alpha cannot mutate
179
+ // the serialized cache held by the SharedValue.
180
+ if (label.alpha !== next) cacheChanged = true;
165
181
  label.alpha = next;
166
182
  }
167
183
  }
168
184
 
169
- labelAlphas.set(alphas);
185
+ if (cacheChanged) labelAlphas.set(alphas);
170
186
 
171
187
  // Collect visible labels
172
188
  const raw: XAxisEntry[] = [];