react-native-livechart 4.21.0 → 4.21.1

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 (48) hide show
  1. package/dist/components/AxisLabelOverlay.d.ts +5 -1
  2. package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +4 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  6. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  7. package/dist/components/ExtremaConnectorOverlay.d.ts +5 -1
  8. package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -1
  9. package/dist/components/LiveChart.d.ts.map +1 -1
  10. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  11. package/dist/components/MarkerOverlay.d.ts +4 -2
  12. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  13. package/dist/components/ReferenceLineOverlay.d.ts +1 -1
  14. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  15. package/dist/core/chartDataPresence.d.ts +8 -0
  16. package/dist/core/chartDataPresence.d.ts.map +1 -0
  17. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  18. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  19. package/dist/draw/markerAtlas.d.ts +3 -2
  20. package/dist/draw/markerAtlas.d.ts.map +1 -1
  21. package/dist/hooks/useChartReveal.d.ts +1 -1
  22. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  23. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  24. package/dist/hooks/useDegen.d.ts.map +1 -1
  25. package/dist/hooks/useLiveChartHasData.d.ts +2 -2
  26. package/dist/hooks/useLiveChartHasData.d.ts.map +1 -1
  27. package/dist/hooks/useReverseMorphEngineInputs.d.ts.map +1 -1
  28. package/dist/types.d.ts +3 -3
  29. package/package.json +1 -1
  30. package/src/components/AxisLabelOverlay.tsx +162 -81
  31. package/src/components/CrosshairOverlay.tsx +394 -147
  32. package/src/components/CustomMarkerOverlay.tsx +9 -6
  33. package/src/components/DegenParticlesOverlay.tsx +34 -23
  34. package/src/components/ExtremaConnectorOverlay.tsx +43 -3
  35. package/src/components/LiveChart.tsx +1692 -1025
  36. package/src/components/LiveChartSeries.tsx +445 -296
  37. package/src/components/MarkerOverlay.tsx +215 -156
  38. package/src/components/ReferenceLineOverlay.tsx +77 -50
  39. package/src/core/chartDataPresence.ts +24 -0
  40. package/src/core/useLiveChartEngine.ts +6 -3
  41. package/src/core/useLiveChartSeriesEngine.ts +2 -0
  42. package/src/draw/markerAtlas.ts +8 -3
  43. package/src/hooks/useChartReveal.ts +1 -1
  44. package/src/hooks/useCrosshair.ts +69 -46
  45. package/src/hooks/useDegen.ts +82 -53
  46. package/src/hooks/useLiveChartHasData.ts +8 -4
  47. package/src/hooks/useReverseMorphEngineInputs.ts +11 -20
  48. package/src/types.ts +3 -3
@@ -30,6 +30,7 @@ import {
30
30
  isConnectorMarker,
31
31
  markerAppearanceSig,
32
32
  type AtlasCell,
33
+ type MarkerAtlasPalette,
33
34
  } from "../draw/markerAtlas";
34
35
  import {
35
36
  markersSignature,
@@ -45,6 +46,7 @@ import type {
45
46
  LiveChartPalette,
46
47
  LiveChartPoint,
47
48
  Marker,
49
+ MarkerGroupBadge,
48
50
  MarkerRenderContext,
49
51
  SeriesConfig,
50
52
  } from "../types";
@@ -85,7 +87,12 @@ function pushGroupCountBadge(
85
87
  const bg = cells[groupBgSig(repColor)];
86
88
  if (bg) {
87
89
  transforms.push(
88
- Skia.RSXform(invScale * mul, 0, cx - (bg.w * mul) / 2, cy - (bg.h * mul) / 2),
90
+ Skia.RSXform(
91
+ invScale * mul,
92
+ 0,
93
+ cx - (bg.w * mul) / 2,
94
+ cy - (bg.h * mul) / 2,
95
+ ),
89
96
  );
90
97
  sprites.push(bg.rect);
91
98
  }
@@ -219,18 +226,7 @@ function ConnectorGlyph({
219
226
  *
220
227
  * Glyph precedence per marker: `image` → `icon` (text) → built-in `kind` shape.
221
228
  */
222
- export function MarkerOverlay({
223
- markers,
224
- engine,
225
- padding,
226
- palette,
227
- font,
228
- series,
229
- lineData,
230
- lineLinear,
231
- renderMarker,
232
- cluster,
233
- }: {
229
+ type MarkerOverlayProps = {
234
230
  markers: SharedValue<Marker[]>;
235
231
  engine: ChartEngineLayout;
236
232
  padding: ChartPadding;
@@ -250,9 +246,24 @@ export function MarkerOverlay({
250
246
  renderMarker?: (marker: Marker, ctx: MarkerRenderContext) => unknown;
251
247
  /** Collision config; `"stacked"` fans/collapses co-located markers. */
252
248
  cluster: ResolvedMarkerCluster;
253
- }) {
249
+ };
250
+
251
+ function useMarkerOverlayModel({
252
+ markers,
253
+ engine,
254
+ padding,
255
+ palette,
256
+ font,
257
+ series,
258
+ lineData,
259
+ lineLinear,
260
+ renderMarker,
261
+ cluster,
262
+ }: MarkerOverlayProps) {
254
263
  // Seed from the current markers at mount; the reaction below keeps it in sync.
255
- const [snapshot, setSnapshot] = useState<Marker[]>(() => markers.get().slice());
264
+ const [snapshot, setSnapshot] = useState<Marker[]>(() =>
265
+ markers.get().slice(),
266
+ );
256
267
 
257
268
  // Read the `markers` prop from closure rather than a SharedValue passed
258
269
  // through `scheduleOnRN` (which loses `.get()`); mirrors the data model.
@@ -295,44 +306,85 @@ export function MarkerOverlay({
295
306
  return o;
296
307
  }, [customKey]);
297
308
 
298
- // Rebuild the atlas image only when the set of distinct appearances or the
299
- // theme/font changes — never per frame. `markersSignature` already drives the
300
- // snapshot, so this useMemo re-runs at most at the snapshot cadence.
301
- const appearanceKey = useMemo(() => {
302
- const sigs = new Set<string>();
303
- for (let i = 0; i < snapshot.length; i++) {
304
- const m = snapshot[i];
305
- if (!isConnectorMarker(m) && !customIds[m.id])
306
- sigs.add(markerAppearanceSig(m));
307
- }
308
- return Array.from(sigs).sort().join("\x1e");
309
- }, [snapshot, customIds]);
310
- // Cells bake in resolved colors; include the palette fields they depend on.
311
- const paletteKey = `${palette.bgRgb.join(",")}|${palette.line}|${palette.refLine}|${palette.dotUp}|${palette.refLabel}`;
312
309
  // Rasterize at the screen's device-pixel ratio so sprites stay crisp on
313
310
  // retina canvases instead of being upscaled from a logical-sized texture.
314
311
  const dpr = PixelRatio.get();
315
312
  const clusterStacked = cluster.mode === "stacked";
313
+ const [paletteBgR, paletteBgG, paletteBgB] = palette.bgRgb;
314
+ const paletteLine = palette.line;
315
+ const paletteRefLine = palette.refLine;
316
+ const paletteDotUp = palette.dotUp;
317
+ const paletteRefLabel = palette.refLabel;
318
+ const atlasPalette = useMemo<MarkerAtlasPalette>(
319
+ () => ({
320
+ bgRgb: [paletteBgR, paletteBgG, paletteBgB],
321
+ line: paletteLine,
322
+ refLine: paletteRefLine,
323
+ dotUp: paletteDotUp,
324
+ refLabel: paletteRefLabel,
325
+ }),
326
+ [
327
+ paletteBgR,
328
+ paletteBgG,
329
+ paletteBgB,
330
+ paletteLine,
331
+ paletteRefLine,
332
+ paletteDotUp,
333
+ paletteRefLabel,
334
+ ],
335
+ );
316
336
  // A dedicated group badge (object form) bakes one extra atlas cell; its image
317
337
  // identity + styling drive the rebuild key alongside the marker appearances.
318
338
  const groupBadgeCfg =
319
339
  typeof cluster.groupBadge === "object" ? cluster.groupBadge : undefined;
340
+ const hasGroupBadge = groupBadgeCfg !== undefined;
320
341
  const groupBadgeImage = groupBadgeCfg?.image;
321
- const groupBadgeKey = groupBadgeCfg
322
- ? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}|${groupBadgeCfg.letterSpacing ?? ""}`
323
- : "";
342
+ const groupBadgeIcon = groupBadgeCfg?.icon;
343
+ const groupBadgeColor = groupBadgeCfg?.color;
344
+ const groupBadgePill = groupBadgeCfg?.pill;
345
+ const groupBadgeSize = groupBadgeCfg?.size;
346
+ const groupBadgeLetterSpacing = groupBadgeCfg?.letterSpacing;
347
+ const atlasGroupBadge = useMemo<MarkerGroupBadge | undefined>(
348
+ () =>
349
+ hasGroupBadge
350
+ ? {
351
+ image: groupBadgeImage,
352
+ icon: groupBadgeIcon,
353
+ color: groupBadgeColor,
354
+ pill: groupBadgePill,
355
+ size: groupBadgeSize,
356
+ letterSpacing: groupBadgeLetterSpacing,
357
+ }
358
+ : undefined,
359
+ [
360
+ hasGroupBadge,
361
+ groupBadgeImage,
362
+ groupBadgeIcon,
363
+ groupBadgeColor,
364
+ groupBadgePill,
365
+ groupBadgeSize,
366
+ groupBadgeLetterSpacing,
367
+ ],
368
+ );
324
369
  const atlas = useMemo(
325
370
  () =>
326
371
  buildMarkerAtlas(
327
372
  snapshot.filter((m) => !customIds[m.id]),
328
- palette,
373
+ atlasPalette,
329
374
  font,
330
375
  dpr,
331
376
  clusterStacked,
332
- groupBadgeCfg,
377
+ atlasGroupBadge,
333
378
  ),
334
- // eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey/groupBadgeKey capture the inputs that change cell pixels
335
- [appearanceKey, paletteKey, font, dpr, clusterStacked, groupBadgeKey, groupBadgeImage],
379
+ [
380
+ snapshot,
381
+ customIds,
382
+ atlasPalette,
383
+ font,
384
+ dpr,
385
+ clusterStacked,
386
+ atlasGroupBadge,
387
+ ],
336
388
  );
337
389
  const cells: Record<string, AtlasCell> = atlas.cells;
338
390
  const atlasImage = atlas.image;
@@ -367,133 +419,133 @@ export function MarkerOverlay({
367
419
  // Single per-frame worklet: project all markers, then emit a transform +
368
420
  // source rect for each visible atlas marker. Three mappers total regardless
369
421
  // of marker count (this + the two thin readers below).
370
- const atlasData = useDerivedValue(
371
- () => {
372
- const ms = markers.get();
373
- const proj = projRef.current!;
374
- proj.tick = !proj.tick;
375
- const buf = proj.tick ? proj.a : proj.b;
376
- projectMarkers(ms, buf, {
377
- canvasWidth: engine.canvasWidth.get(),
378
- canvasHeight: engine.canvasHeight.get(),
379
- padTop: padding.top,
380
- padBottom: padding.bottom,
381
- padLeft: padding.left,
382
- padRight: padding.right,
383
- timestamp: engine.timestamp.get(),
384
- displayWindow: engine.displayWindow.get(),
385
- displayMin: engine.displayMin.get(),
386
- displayMax: engine.displayMax.get(),
387
- series: series?.get(),
388
- lineData: lineData?.get(),
389
- lineLinear,
390
- });
391
- clusterMarkers(ms, buf, {
392
- config: cluster,
393
- minY: 0,
394
- maxY: engine.canvasHeight.get(),
395
- });
396
- const atlasFrames = atlasFrameRef.current!;
397
- atlasFrames.tick = !atlasFrames.tick;
398
- const frame = atlasFrames.tick ? atlasFrames.a : atlasFrames.b;
399
- const transforms = frame.transforms;
400
- const sprites = frame.sprites;
401
- transforms.length = 0;
402
- sprites.length = 0;
403
- for (let i = 0; i < ms.length; i++) {
404
- const pt = buf[i];
405
- // Collapsed-cluster members fold into their representative's badge.
406
- if (!pt.visible || pt.hidden) continue;
407
- const m = ms[i];
408
- if (isConnectorMarker(m)) continue;
409
- // Custom-rendered markers are floated as RN views, not drawn here.
410
- if (customIds[m.id]) continue;
411
- // Collapsed cluster. By default a round count badge; with
412
- // `groupBadge: "marker"` the representative marker's own glyph, or with a
413
- // `MarkerGroupBadge` object a dedicated baked badge — either optionally
414
- // carrying a corner count, all within this one `drawAtlas`.
415
- if (pt.isGrouped) {
416
- const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
417
- const gb = cluster.groupBadge;
418
- // Pick the non-count glyph cell: a dedicated group badge (object form)
419
- // or the representative marker's own appearance.
420
- const glyphCell =
421
- typeof gb === "object" && gb !== null
422
- ? cells[GROUP_BADGE_SIG]
423
- : gb === "marker"
424
- ? cells[markerAppearanceSig(m)]
425
- : undefined;
426
- if (glyphCell) {
427
- transforms.push(
428
- Skia.RSXform(
429
- invScale,
430
- 0,
431
- pt.x - glyphCell.w / 2,
432
- pt.y - glyphCell.h / 2,
433
- ),
422
+ const atlasData = useDerivedValue(() => {
423
+ const ms = markers.get();
424
+ const proj = projRef.current!;
425
+ proj.tick = !proj.tick;
426
+ const buf = proj.tick ? proj.a : proj.b;
427
+ projectMarkers(ms, buf, {
428
+ canvasWidth: engine.canvasWidth.get(),
429
+ canvasHeight: engine.canvasHeight.get(),
430
+ padTop: padding.top,
431
+ padBottom: padding.bottom,
432
+ padLeft: padding.left,
433
+ padRight: padding.right,
434
+ timestamp: engine.timestamp.get(),
435
+ displayWindow: engine.displayWindow.get(),
436
+ displayMin: engine.displayMin.get(),
437
+ displayMax: engine.displayMax.get(),
438
+ series: series?.get(),
439
+ lineData: lineData?.get(),
440
+ lineLinear,
441
+ });
442
+ clusterMarkers(ms, buf, {
443
+ config: cluster,
444
+ minY: 0,
445
+ maxY: engine.canvasHeight.get(),
446
+ });
447
+ const atlasFrames = atlasFrameRef.current!;
448
+ atlasFrames.tick = !atlasFrames.tick;
449
+ const frame = atlasFrames.tick ? atlasFrames.a : atlasFrames.b;
450
+ const transforms = frame.transforms;
451
+ const sprites = frame.sprites;
452
+ transforms.length = 0;
453
+ sprites.length = 0;
454
+ for (let i = 0; i < ms.length; i++) {
455
+ const pt = buf[i];
456
+ // Collapsed-cluster members fold into their representative's badge.
457
+ if (!pt.visible || pt.hidden) continue;
458
+ const m = ms[i];
459
+ if (isConnectorMarker(m)) continue;
460
+ // Custom-rendered markers are floated as RN views, not drawn here.
461
+ if (customIds[m.id]) continue;
462
+ // Collapsed cluster. By default a round count badge; with
463
+ // `groupBadge: "marker"` the representative marker's own glyph, or with a
464
+ // `MarkerGroupBadge` object a dedicated baked badge — either optionally
465
+ // carrying a corner count, all within this one `drawAtlas`.
466
+ if (pt.isGrouped) {
467
+ const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
468
+ const gb = cluster.groupBadge;
469
+ // Pick the non-count glyph cell: a dedicated group badge (object form)
470
+ // or the representative marker's own appearance.
471
+ const glyphCell =
472
+ typeof gb === "object" && gb !== null
473
+ ? cells[GROUP_BADGE_SIG]
474
+ : gb === "marker"
475
+ ? cells[markerAppearanceSig(m)]
476
+ : undefined;
477
+ if (glyphCell) {
478
+ transforms.push(
479
+ Skia.RSXform(
480
+ invScale,
481
+ 0,
482
+ pt.x - glyphCell.w / 2,
483
+ pt.y - glyphCell.h / 2,
484
+ ),
485
+ );
486
+ sprites.push(glyphCell.rect);
487
+ if (cluster.showGroupCount) {
488
+ pushGroupCountBadge(
489
+ transforms,
490
+ sprites,
491
+ cells,
492
+ digitWidths,
493
+ invScale,
494
+ pt.x + (glyphCell.w / 2) * GROUP_CORNER_INSET,
495
+ pt.y - (glyphCell.h / 2) * GROUP_CORNER_INSET,
496
+ repColor,
497
+ pt.groupCount,
498
+ GROUP_CORNER_SCALE,
499
+ groupBadgeCfg?.letterSpacing ?? 0,
434
500
  );
435
- sprites.push(glyphCell.rect);
436
- if (cluster.showGroupCount) {
437
- pushGroupCountBadge(
438
- transforms,
439
- sprites,
440
- cells,
441
- digitWidths,
442
- invScale,
443
- pt.x + (glyphCell.w / 2) * GROUP_CORNER_INSET,
444
- pt.y - (glyphCell.h / 2) * GROUP_CORNER_INSET,
445
- repColor,
446
- pt.groupCount,
447
- GROUP_CORNER_SCALE,
448
- groupBadgeCfg?.letterSpacing ?? 0,
449
- );
450
- }
451
- continue;
452
501
  }
453
- // Default — or fall back here when a non-count badge has no baked cell
454
- // (e.g. an empty group-badge config): the round count badge.
455
- pushGroupCountBadge(
456
- transforms,
457
- sprites,
458
- cells,
459
- digitWidths,
460
- invScale,
461
- pt.x,
462
- pt.y,
463
- repColor,
464
- pt.groupCount,
465
- 1,
466
- groupBadgeCfg?.letterSpacing ?? 0,
467
- );
468
502
  continue;
469
503
  }
470
- const cell = cells[markerAppearanceSig(m)];
471
- if (!cell) continue;
472
- // Center the cell on the projected point. The cell's source rect is in
473
- // device pixels, so scale by 1/dpr to land it at its logical size.
474
- transforms.push(
475
- Skia.RSXform(invScale, 0, pt.x - cell.w / 2, pt.y - cell.h / 2),
504
+ // Default — or fall back here when a non-count badge has no baked cell
505
+ // (e.g. an empty group-badge config): the round count badge.
506
+ pushGroupCountBadge(
507
+ transforms,
508
+ sprites,
509
+ cells,
510
+ digitWidths,
511
+ invScale,
512
+ pt.x,
513
+ pt.y,
514
+ repColor,
515
+ pt.groupCount,
516
+ 1,
517
+ groupBadgeCfg?.letterSpacing ?? 0,
476
518
  );
477
- sprites.push(cell.rect);
519
+ continue;
478
520
  }
479
- return frame;
480
- },
481
- [
482
- cells,
483
- customIds,
484
- invScale,
485
- digitWidths,
486
- markers,
487
- engine,
488
- padding,
489
- palette,
490
- series,
491
- lineData,
492
- lineLinear,
493
- cluster,
494
- ],
521
+ const cell = cells[markerAppearanceSig(m)];
522
+ if (!cell) continue;
523
+ // Center the cell on the projected point. The cell's source rect is in
524
+ // device pixels, so scale by 1/dpr to land it at its logical size.
525
+ transforms.push(
526
+ Skia.RSXform(invScale, 0, pt.x - cell.w / 2, pt.y - cell.h / 2),
527
+ );
528
+ sprites.push(cell.rect);
529
+ }
530
+ return frame;
531
+ }, [
532
+ cells,
533
+ customIds,
534
+ invScale,
535
+ digitWidths,
536
+ markers,
537
+ engine,
538
+ padding,
539
+ palette,
540
+ series,
541
+ lineData,
542
+ lineLinear,
543
+ cluster,
544
+ ]);
545
+ const transforms = useDerivedValue(
546
+ () => atlasData.get().transforms,
547
+ [atlasData],
495
548
  );
496
- const transforms = useDerivedValue(() => atlasData.get().transforms, [atlasData]);
497
549
  const sprites = useDerivedValue(() => atlasData.get().sprites, [atlasData]);
498
550
 
499
551
  const axisY = useDerivedValue(
@@ -502,6 +554,13 @@ export function MarkerOverlay({
502
554
 
503
555
  const connectors = snapshot.filter(isConnectorMarker);
504
556
 
557
+ return { atlasImage, sprites, transforms, connectors, axisY };
558
+ }
559
+
560
+ export function MarkerOverlay(props: MarkerOverlayProps) {
561
+ const { engine, padding, palette, series, lineData, lineLinear } = props;
562
+ const { atlasImage, sprites, transforms, connectors, axisY } =
563
+ useMarkerOverlayModel(props);
505
564
  return (
506
565
  <Group>
507
566
  {atlasImage && (
@@ -18,7 +18,10 @@ import {
18
18
  type ReferenceLineLayout,
19
19
  } from "../hooks/useReferenceLine";
20
20
  import { MONO_FONT_FAMILY } from "../lib/monoFontFamily";
21
- import { referenceLineForm, resolveReferenceBadge } from "../math/referenceLines";
21
+ import {
22
+ referenceLineForm,
23
+ resolveReferenceBadge,
24
+ } from "../math/referenceLines";
22
25
  import type { FontConfig, LiveChartPalette, ReferenceLine } from "../types";
23
26
  import { ReferenceLineSeriesOverlay } from "./ReferenceLineSeriesOverlay";
24
27
 
@@ -65,7 +68,7 @@ type ReferenceLineOverlayProps = {
65
68
  * this line — used when a custom `renderReferenceLine` element owns the tag.
66
69
  * The line / band stroke and a badged line's dashed connector still draw. No
67
70
  * effect on the base pass.
68
- */
71
+ */
69
72
  suppressTag?: boolean;
70
73
  /**
71
74
  * Suppress the built-in tag only while the line is off-axis. Used by
@@ -117,6 +120,52 @@ export function ReferenceLineOverlay(props: ReferenceLineOverlayProps) {
117
120
  return <ReferenceLineStaticOverlay {...props} />;
118
121
  }
119
122
 
123
+ function resolveStaticReferenceLineAppearance(
124
+ line: ReferenceLine,
125
+ palette: LiveChartPalette,
126
+ fontProp: FontConfig | undefined,
127
+ ) {
128
+ const form = referenceLineForm(line);
129
+ const isBand = form === "value-band" || form === "time-band";
130
+ const color = line.color ?? palette.refLine;
131
+ const badge = resolveReferenceBadge(line);
132
+ const badgeHasFontOverride =
133
+ badge != null &&
134
+ (badge.fontSize != null ||
135
+ badge.fontFamily != null ||
136
+ badge.fontWeight != null);
137
+
138
+ return {
139
+ form,
140
+ isBand,
141
+ color,
142
+ fillColor: line.fillColor ?? color,
143
+ strokeOpacity: Math.max(0, Math.min(1, line.strokeOpacity ?? 1)),
144
+ badge,
145
+ badgeHasFontOverride,
146
+ badgeFontConfig: badgeHasFontOverride
147
+ ? {
148
+ ...fontProp,
149
+ fontFamily: badge?.fontFamily ?? fontProp?.fontFamily,
150
+ fontSize: badge?.fontSize ?? fontProp?.fontSize,
151
+ fontWeight: badge?.fontWeight ?? fontProp?.fontWeight,
152
+ }
153
+ : fontProp,
154
+ labelColor:
155
+ badge?.textColor ?? line.labelColor ?? line.color ?? palette.refLabel,
156
+ strokeWidth: line.strokeWidth ?? 1,
157
+ intervals: line.intervals ?? [4, 4],
158
+ bandFillOpacity: line.fillOpacity ?? BAND_FILL_OPACITY,
159
+ hasBandBorder: isBand && line.strokeWidth !== undefined,
160
+ badgeBackground: badge?.background ?? palette.tooltipBg,
161
+ badgeBorderColor: badge?.borderColor ?? color,
162
+ badgeBorderWidth: badge?.borderWidth ?? 1,
163
+ badgeRadius: badge?.radius ?? BADGE_PILL_RADIUS,
164
+ badgeOffsetX: badge?.offsetX ?? 0,
165
+ badgeOffsetY: badge?.offsetY ?? 0,
166
+ };
167
+ }
168
+
120
169
  function ReferenceLineStaticOverlay({
121
170
  engine,
122
171
  padding,
@@ -136,33 +185,30 @@ function ReferenceLineStaticOverlay({
136
185
  labelRightMargin,
137
186
  gridEndGap,
138
187
  }: ReferenceLineOverlayProps) {
139
- const form = referenceLineForm(line);
140
- const isBand = form === "value-band" || form === "time-band";
188
+ const {
189
+ form,
190
+ isBand,
191
+ color,
192
+ fillColor,
193
+ strokeOpacity,
194
+ badge,
195
+ badgeHasFontOverride,
196
+ badgeFontConfig,
197
+ labelColor,
198
+ strokeWidth,
199
+ intervals,
200
+ bandFillOpacity,
201
+ hasBandBorder,
202
+ badgeBackground,
203
+ badgeBorderColor,
204
+ badgeBorderWidth,
205
+ badgeRadius,
206
+ badgeOffsetX,
207
+ badgeOffsetY,
208
+ } = resolveStaticReferenceLineAppearance(line, palette, fontProp);
141
209
 
142
- const color = line.color ?? palette.refLine;
143
- const fillColor = line.fillColor ?? color;
144
- const strokeOpacity = Math.max(0, Math.min(1, line.strokeOpacity ?? 1));
145
-
146
- // Resolved badge appearance (badge config → fallback flat fields → theme).
147
- const badge = resolveReferenceBadge(line);
148
-
149
- // Per-badge font (size/family/weight) override; reuses the chart `font` when no
150
- // badge font knob is set, so plain lines and gutter labels are unchanged. The
151
- // built font drives pill measurement (via useReferenceLine) and the badge text.
152
- const badgeHasFontOverride =
153
- badge != null &&
154
- (badge.fontSize != null ||
155
- badge.fontFamily != null ||
156
- badge.fontWeight != null);
157
210
  const badgeFontOverride = useChartSkiaFont(
158
- badgeHasFontOverride
159
- ? {
160
- ...fontProp,
161
- fontFamily: badge?.fontFamily ?? fontProp?.fontFamily,
162
- fontSize: badge?.fontSize ?? fontProp?.fontSize,
163
- fontWeight: badge?.fontWeight ?? fontProp?.fontWeight,
164
- }
165
- : fontProp,
211
+ badgeFontConfig,
166
212
  MONO_FONT_FAMILY,
167
213
  palette.labelFontSize,
168
214
  );
@@ -182,27 +228,6 @@ function ReferenceLineStaticOverlay({
182
228
  font,
183
229
  );
184
230
 
185
- const labelColor =
186
- badge?.textColor ?? line.labelColor ?? line.color ?? palette.refLabel;
187
- const strokeWidth = line.strokeWidth ?? 1;
188
- const intervals = line.intervals ?? [4, 4];
189
-
190
- // Band fill + optional dashed border (border only when strokeWidth is set).
191
- const bandFillOpacity = line.fillOpacity ?? BAND_FILL_OPACITY;
192
- const hasBandBorder = isBand && line.strokeWidth !== undefined;
193
-
194
- const badgeBackground = badge?.background ?? palette.tooltipBg;
195
- const badgeBorderColor = badge?.borderColor ?? color;
196
- const badgeBorderWidth = badge?.borderWidth ?? 1;
197
- const badgeRadius = badge?.radius ?? BADGE_PILL_RADIUS;
198
- // Nudge the whole badge (connector + pill + chevron + icon + label) off its anchor.
199
- const badgeOffsetX = badge?.offsetX ?? 0;
200
- const badgeOffsetY = badge?.offsetY ?? 0;
201
- const badgeTransform =
202
- badgeOffsetX !== 0 || badgeOffsetY !== 0
203
- ? [{ translateX: badgeOffsetX }, { translateY: badgeOffsetY }]
204
- : undefined;
205
-
206
231
  if (!badgeLayer) {
207
232
  return (
208
233
  <ReferenceLineBasePass
@@ -412,7 +437,7 @@ function ReferenceLineBadgePass({
412
437
  const customTagActive =
413
438
  suppressTag || (suppressTagWhenOffAxis && l.offAxis);
414
439
  const customWidth = customTagActive
415
- ? customTagWidths?.get()[index] ?? 0
440
+ ? (customTagWidths?.get()[index] ?? 0)
416
441
  : 0;
417
442
  if (customWidth > 0) {
418
443
  if (badgePosition === "left") {
@@ -466,7 +491,9 @@ function ReferenceLineBadgePass({
466
491
  const grouped = groupHidden ? groupHidden.get()[index] === true : false;
467
492
  const customTagActive =
468
493
  suppressTag || (suppressTagWhenOffAxis && l.offAxis);
469
- return !customTagActive && !grouped && l.visible && l.label.length > 0 ? 1 : 0;
494
+ return !customTagActive && !grouped && l.visible && l.label.length > 0
495
+ ? 1
496
+ : 0;
470
497
  });
471
498
  const iconOpacity = useDerivedValue(() => {
472
499
  const l = layout.get();
@@ -0,0 +1,24 @@
1
+ import type { CandlePoint, LiveChartPoint, SeriesConfig } from "../types";
2
+
3
+ /** A single historical line sample is enough to anchor the live tip. */
4
+ export function hasLineChartData(points: LiveChartPoint[]): boolean {
5
+ "worklet";
6
+ return points.length > 0;
7
+ }
8
+
9
+ /** A single committed OHLC bucket is drawable as a candle. */
10
+ export function hasCandleChartData(
11
+ candles: CandlePoint[] | undefined,
12
+ ): boolean {
13
+ "worklet";
14
+ return (candles?.length ?? 0) > 0;
15
+ }
16
+
17
+ /** Multi-series charts are non-empty when any series has one sample. */
18
+ export function hasMultiSeriesChartData(series: SeriesConfig[]): boolean {
19
+ "worklet";
20
+ for (let i = 0; i < series.length; i++) {
21
+ if (series[i].data.length > 0) return true;
22
+ }
23
+ return false;
24
+ }
@@ -586,9 +586,12 @@ export function useLiveChartEngine(
586
586
  extremaMinTime,
587
587
  extremaMaxTime,
588
588
  };
589
- const [frameScratchRef] = useState(() => ({
590
- current: makeEngineFrameScratch(),
591
- }));
589
+ const frameScratchRef = useRef<EngineFrameScratch | null>(null);
590
+ if (frameScratchRef.current === null) {
591
+ // React permits this predictable lazy-ref initialization: https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents
592
+ // react-doctor-disable-next-line react-doctor/no-ref-current-in-render -- false positive for React's documented lazy-ref exception
593
+ frameScratchRef.current = makeEngineFrameScratch();
594
+ }
592
595
 
593
596
  // `autostart=false` registers the frame callback without running it — the live
594
597
  // loop is fully inert in static mode (the invariant that makes this worth it).
@@ -356,6 +356,8 @@ export function useLiveChartSeriesEngine(
356
356
  // Reused per-frame output buffers (ping-ponged) — see applyLiveChartSeriesEngineFrame.
357
357
  const scratchRef = useRef<MultiSeriesEngineScratch | null>(null);
358
358
  if (scratchRef.current === null) {
359
+ // React permits this predictable lazy-ref initialization: https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents
360
+ // react-doctor-disable-next-line react-doctor/no-ref-current-in-render -- false positive for React's documented lazy-ref exception
359
361
  scratchRef.current = makeMultiSeriesEngineScratch();
360
362
  }
361
363