react-native-livechart 3.4.0 → 3.5.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.
@@ -74,7 +74,9 @@ export function markerAppearanceSig(m: Marker): string {
74
74
 
75
75
  /** One packed glyph in the atlas image: its source rect + box size. */
76
76
  export interface AtlasCell {
77
+ /** Source rect into the (hi-res) atlas texture, in **device** pixels. */
77
78
  rect: SkRect;
79
+ /** Logical (DPR-independent) cell width/height, used to center the blit. */
78
80
  w: number;
79
81
  h: number;
80
82
  }
@@ -84,9 +86,16 @@ export interface MarkerAtlas {
84
86
  image: SkImage | null;
85
87
  /** appearance-signature → cell. */
86
88
  cells: Record<string, AtlasCell>;
89
+ /**
90
+ * Device-pixel scale the texture was rasterized at. Cell `rect`s are in
91
+ * texture (device) pixels, while `w`/`h` stay in logical pixels — so the
92
+ * per-frame blit must apply an `RSXform` scale of `1 / scale` to map the
93
+ * hi-res cell back to its logical on-canvas size. See `buildMarkerAtlas`.
94
+ */
95
+ scale: number;
87
96
  }
88
97
 
89
- const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {} };
98
+ const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {}, scale: 1 };
90
99
 
91
100
  function fillPaint(color: string): SkPaint {
92
101
  const p = Skia.Paint();
@@ -258,11 +267,18 @@ function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec
258
267
  *
259
268
  * Connector kinds (see `isConnectorMarker`) are skipped — they render via a
260
269
  * self-projecting fallback component.
270
+ *
271
+ * The texture is rasterized at `scale` (the screen's device-pixel ratio) so the
272
+ * cells carry enough resolution to stay crisp when blitted onto a retina canvas
273
+ * — every glyph is recorded in logical coords then magnified by `scale`, and the
274
+ * per-frame `drawAtlas` shrinks it back with an `RSXform` scale of `1 / scale`.
275
+ * Without this the logical-sized texture is upscaled ~3× on iOS and looks blurry.
261
276
  */
262
277
  export function buildMarkerAtlas(
263
278
  markers: Marker[],
264
279
  palette: LiveChartPalette,
265
280
  font: SkFont,
281
+ scale = 1,
266
282
  ): MarkerAtlas {
267
283
  const seen = new Set<string>();
268
284
  const specs: { sig: string; spec: CellSpec }[] = [];
@@ -282,11 +298,16 @@ export function buildMarkerAtlas(
282
298
  totalW += specs[i].spec.w;
283
299
  if (specs[i].spec.h > maxH) maxH = specs[i].spec.h;
284
300
  }
301
+ // Logical packed size; the texture itself is `scale`× larger in each axis.
285
302
  const W = Math.max(1, Math.ceil(totalW));
286
303
  const H = Math.max(1, Math.ceil(maxH));
304
+ const texW = Math.max(1, Math.ceil(W * scale));
305
+ const texH = Math.max(1, Math.ceil(H * scale));
287
306
 
288
307
  const recorder = Skia.PictureRecorder();
289
- const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, W, H));
308
+ const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, texW, texH));
309
+ // Magnify so glyphs drawn in logical coords fill the hi-res texture.
310
+ canvas.scale(scale, scale);
290
311
  const cells: Record<string, AtlasCell> = {};
291
312
  let x = 0;
292
313
  for (let i = 0; i < specs.length; i++) {
@@ -295,10 +316,15 @@ export function buildMarkerAtlas(
295
316
  canvas.translate(x, 0);
296
317
  spec.draw(canvas, spec.w / 2, H / 2);
297
318
  canvas.restore();
298
- cells[sig] = { rect: Skia.XYWHRect(x, 0, spec.w, H), w: spec.w, h: H };
319
+ // Source rect indexes the device-pixel texture; w/h stay logical.
320
+ cells[sig] = {
321
+ rect: Skia.XYWHRect(x * scale, 0, spec.w * scale, texH),
322
+ w: spec.w,
323
+ h: H,
324
+ };
299
325
  x += spec.w;
300
326
  }
301
327
  const picture = recorder.finishRecordingAsPicture();
302
- const image = drawAsImageFromPicture(picture, { width: W, height: H });
303
- return { image, cells };
328
+ const image = drawAsImageFromPicture(picture, { width: texW, height: texH });
329
+ return { image, cells, scale };
304
330
  }
@@ -22,9 +22,13 @@ export function useChartPaths(
22
22
  engine: SingleEngineState,
23
23
  padding: ChartPadding,
24
24
  morphT?: SharedValue<number>,
25
+ /** When set, also build `thresholdFillPath` — the band between the line and this
26
+ * pixel-Y, closed at the threshold instead of the chart baseline. */
27
+ thresholdY?: SharedValue<number>,
25
28
  ) {
26
29
  const lineBuilder = usePathBuilder();
27
30
  const fillBuilder = usePathBuilder();
31
+ const thresholdFillBuilder = usePathBuilder();
28
32
 
29
33
  const cacheRef = useRef<{
30
34
  emptyPath: SkPath;
@@ -105,5 +109,25 @@ export function useChartPaths(
105
109
  return b.detach();
106
110
  });
107
111
 
108
- return { linePath, fillPath } as const;
112
+ // Threshold-anchored fill: the same spline, closed at `thresholdY` instead of the
113
+ // baseline, so the band lies between the line and the threshold (the profit/loss
114
+ // area). Painted with the hard-split vertical gradient, the part above the split
115
+ // shows the above-color and the part below shows the below-color.
116
+ const thresholdFillPath = useDerivedValue(() => {
117
+ const cache = cacheRef.current!;
118
+ if (!thresholdY) return cache.emptyPath;
119
+ const yT = thresholdY.get();
120
+ const pts = flatPts.get();
121
+ const n = pts.length >> 1;
122
+ if (n < 2 || !Number.isFinite(yT)) return cache.emptyPath;
123
+ const b = thresholdFillBuilder.value;
124
+ b.moveTo(pts[0], pts[1]);
125
+ drawSpline(b, pts, cache.scratch);
126
+ b.lineTo(pts[(n - 1) * 2], yT);
127
+ b.lineTo(pts[0], yT);
128
+ b.close();
129
+ return b.detach();
130
+ });
131
+
132
+ return { linePath, fillPath, thresholdFillPath } as const;
109
133
  }
@@ -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
@@ -77,6 +77,8 @@ export type {
77
77
  SelectionDotRingConfig,
78
78
  SeriesConfig,
79
79
  ThemeMode,
80
+ ThresholdConfig,
81
+ ThresholdLineConfig,
80
82
  TradeEvent,
81
83
  ValueLineConfig,
82
84
  XAxisConfig,
@@ -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
+ }
package/src/types.ts CHANGED
@@ -241,6 +241,61 @@ export interface LineConfig {
241
241
  colors?: string[];
242
242
  }
243
243
 
244
+ /**
245
+ * Color the line above vs. below a live threshold value — green above, red below
246
+ * by default. The threshold is **always** a `SharedValue` so it can track a live
247
+ * benchmark (break-even / average cost, VWAP, previous close, a peg) on the UI
248
+ * thread without re-rendering. Drives a hard-split line stroke and, optionally, a
249
+ * tinted profit/loss fill band and a dashed marker line at the threshold.
250
+ *
251
+ * The split stroke supersedes `LineConfig.color`/`colors` and segment recoloring
252
+ * for the main line while a threshold is set.
253
+ */
254
+ export interface ThresholdConfig {
255
+ /**
256
+ * The split value, in Y-axis (price) units. A `SharedValue` — update it with
257
+ * `.set()` and the split tracks live on the UI thread (break-even, VWAP, the
258
+ * previous close, a peg, …).
259
+ */
260
+ value: SharedValue<number>;
261
+ /** Stroke color where the line is at/above `value`. Default: palette up-green (`candleUp`). */
262
+ aboveColor?: string;
263
+ /** Stroke color where the line is below `value`. Default: palette down-red (`candleDown`). */
264
+ belowColor?: string;
265
+ /**
266
+ * Tint the area between the line and `value` (the profit/loss band) toward the
267
+ * above/below colors. Independent of the baseline `gradient` fill — set
268
+ * `gradient={false}` for the threshold band alone. Default `false`.
269
+ */
270
+ fill?: boolean;
271
+ /**
272
+ * Dashed marker line + optional gutter label at the threshold. `true` → a dashed
273
+ * line in the palette reference color; object → styled; omit/`false` → none.
274
+ * Default off.
275
+ */
276
+ line?: boolean | ThresholdLineConfig;
277
+ }
278
+
279
+ /** Dashed marker line drawn at a {@link ThresholdConfig} value. */
280
+ export interface ThresholdLineConfig {
281
+ /** Label text, e.g. `"Break-even"`. */
282
+ label?: string;
283
+ /**
284
+ * Label side. `"left"` sits just inside the plot at the line's left edge —
285
+ * clear of the y-axis labels and the live badge; `"right"` uses the right
286
+ * gutter like a legacy reference line (may overlap y-axis labels). Default `"left"`.
287
+ */
288
+ labelPosition?: "left" | "right";
289
+ /** Line + label color. Defaults to palette `refLine` / `refLabel`. */
290
+ color?: string;
291
+ /** Dash pattern `[dashLength, gapLength]` in pixels. Default `[4, 4]`. */
292
+ intervals?: [number, number];
293
+ /** Line thickness in pixels. Default `1`. */
294
+ strokeWidth?: number;
295
+ /** Append the formatted threshold value to the label. Default `false`. */
296
+ showValue?: boolean;
297
+ }
298
+
244
299
  /** Area fill gradient beneath the chart line. */
245
300
  export interface GradientConfig {
246
301
  /** Opacity at the top of the gradient (near the line). Default `0.35`. */
@@ -1034,6 +1089,12 @@ export interface LiveChartProps extends LiveChartCoreProps {
1034
1089
  * `mutedColors`). Optional dashed `divider` + `label` mark a segment's edge.
1035
1090
  */
1036
1091
  segments?: ChartSegment[];
1092
+ /**
1093
+ * Color the line above vs. below a live threshold value (break-even / average
1094
+ * cost, VWAP, previous close, a peg). Always a `SharedValue` so the split tracks
1095
+ * live on the UI thread. See {@link ThresholdConfig}.
1096
+ */
1097
+ threshold?: ThresholdConfig;
1037
1098
  /** Render the live value as a large text overlay in the top-left. Default `false`. */
1038
1099
  showValue?: boolean;
1039
1100
  /** Tint the `showValue` text by momentum (green up / red down). Default `false`. */