react-native-livechart 4.8.0 → 4.9.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.
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/ThresholdLineOverlay.d.ts +4 -1
- package/dist/components/ThresholdLineOverlay.d.ts.map +1 -1
- package/dist/components/ThresholdSplitShader.d.ts +26 -0
- package/dist/components/ThresholdSplitShader.d.ts.map +1 -0
- package/dist/core/liveChartEngineTick.d.ts +9 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +22 -5
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +14 -0
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts +6 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useChartSkiaFont.d.ts +2 -1
- package/dist/hooks/useChartSkiaFont.d.ts.map +1 -1
- package/dist/hooks/useThreshold.d.ts +63 -6
- package/dist/hooks/useThreshold.d.ts.map +1 -1
- package/dist/math/threshold.d.ts +82 -0
- package/dist/math/threshold.d.ts.map +1 -1
- package/dist/types.d.ts +77 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/LiveChart.tsx +271 -42
- package/src/components/LiveChartSeries.tsx +5 -1
- package/src/components/ThresholdLineOverlay.tsx +35 -5
- package/src/components/ThresholdSplitShader.tsx +92 -0
- package/src/core/liveChartEngineTick.ts +45 -4
- package/src/core/liveChartSeriesEngineTick.ts +6 -0
- package/src/core/resolveConfig.ts +32 -6
- package/src/core/useLiveChartEngine.ts +35 -0
- package/src/hooks/useChartPaths.ts +51 -7
- package/src/hooks/useChartSkiaFont.ts +31 -5
- package/src/hooks/useThreshold.ts +267 -10
- package/src/math/threshold.ts +250 -0
- package/src/types.ts +78 -18
|
@@ -2,6 +2,10 @@ import type { CandlePoint, LiveChartPoint } from "../types";
|
|
|
2
2
|
|
|
3
3
|
import { MOTION_METRICS_DEFAULTS } from "../constants";
|
|
4
4
|
import { lerp } from "../math/lerp";
|
|
5
|
+
import { thresholdRangeMinMax } from "../math/threshold";
|
|
6
|
+
|
|
7
|
+
/** Scratch for the threshold range fold — only alive within one tick call. */
|
|
8
|
+
const THRESHOLD_RANGE_SCRATCH: [number, number] = [0, 0];
|
|
5
9
|
|
|
6
10
|
export interface EngineTickMutable {
|
|
7
11
|
displayValue: number;
|
|
@@ -48,6 +52,15 @@ export interface EngineTickInput {
|
|
|
48
52
|
referenceValue: number | undefined;
|
|
49
53
|
/** Additional reference values (lines + bands) folded into the Y range. */
|
|
50
54
|
referenceValues?: number[];
|
|
55
|
+
/**
|
|
56
|
+
* Time-varying threshold series (`threshold.includeInRange`): its min/max over
|
|
57
|
+
* the visible window is folded into the Y range, like {@link referenceValues}
|
|
58
|
+
* but windowed each tick.
|
|
59
|
+
*/
|
|
60
|
+
thresholdRangePoints?: LiveChartPoint[];
|
|
61
|
+
/** Whether {@link thresholdRangePoints} extends flat past its last point to
|
|
62
|
+
* "now" (`threshold.extendToNow`). Default `true`. */
|
|
63
|
+
thresholdRangeExtendToNow?: boolean;
|
|
51
64
|
/** Clamp the computed lower bound at 0. */
|
|
52
65
|
nonNegative?: boolean;
|
|
53
66
|
/** Hard cap for the computed upper bound. */
|
|
@@ -222,7 +235,9 @@ export function tickLiveChartEngineFrame(
|
|
|
222
235
|
}
|
|
223
236
|
}
|
|
224
237
|
const lc = input.liveCandle;
|
|
225
|
-
|
|
238
|
+
// Fold the in-progress candle in only while its bucket is visible — when
|
|
239
|
+
// scrolled back past it, the live candle must not stretch the Y range.
|
|
240
|
+
if (lc && (!scrolledBack || lc.time <= state.timestamp)) {
|
|
226
241
|
/* istanbul ignore next -- trivial min/max */
|
|
227
242
|
if (lc.low < tMin) {
|
|
228
243
|
tMin = lc.low;
|
|
@@ -244,6 +259,11 @@ export function tickLiveChartEngineFrame(
|
|
|
244
259
|
else hi = mid;
|
|
245
260
|
}
|
|
246
261
|
for (let i = lo; i < points.length; i++) {
|
|
262
|
+
// While scrolled back, stop at the frozen right edge (mirrors the candle
|
|
263
|
+
// scan) so newer points don't inflate the visible Y range. Following
|
|
264
|
+
// live, keep the tail inclusive — feed timestamps can run slightly ahead
|
|
265
|
+
// of the local clock and must not flicker out of the range.
|
|
266
|
+
if (scrolledBack && points[i].time > state.timestamp) break;
|
|
247
267
|
const v = points[i].value;
|
|
248
268
|
if (v < tMin) {
|
|
249
269
|
tMin = v;
|
|
@@ -266,9 +286,15 @@ export function tickLiveChartEngineFrame(
|
|
|
266
286
|
state.extremaMinTime = hasMin ? minTime : NaN;
|
|
267
287
|
state.extremaMaxTime = hasMax ? maxTime : NaN;
|
|
268
288
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
289
|
+
// Keep the animated live tip inside the range — but only while it's actually
|
|
290
|
+
// in the window. Scrolled back, the live value sits beyond the frozen right
|
|
291
|
+
// edge; folding it in would pin the Y range to the live price and stop the
|
|
292
|
+
// axis from adapting to the visible history.
|
|
293
|
+
if (!scrolledBack) {
|
|
294
|
+
const cv = state.displayValue;
|
|
295
|
+
if (cv < tMin) tMin = cv;
|
|
296
|
+
if (cv > tMax) tMax = cv;
|
|
297
|
+
}
|
|
272
298
|
|
|
273
299
|
const ref = input.referenceValue;
|
|
274
300
|
if (ref !== undefined) {
|
|
@@ -285,6 +311,21 @@ export function tickLiveChartEngineFrame(
|
|
|
285
311
|
}
|
|
286
312
|
}
|
|
287
313
|
|
|
314
|
+
const thrPts = input.thresholdRangePoints;
|
|
315
|
+
if (thrPts !== undefined && thrPts.length > 0) {
|
|
316
|
+
const mm = thresholdRangeMinMax(
|
|
317
|
+
thrPts,
|
|
318
|
+
state.timestamp,
|
|
319
|
+
state.displayWindow,
|
|
320
|
+
input.thresholdRangeExtendToNow ?? true,
|
|
321
|
+
THRESHOLD_RANGE_SCRATCH,
|
|
322
|
+
);
|
|
323
|
+
if (mm !== null) {
|
|
324
|
+
if (mm[0] < tMin) tMin = mm[0];
|
|
325
|
+
if (mm[1] > tMax) tMax = mm[1];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
288
329
|
const isExaggerate = input.exaggerate;
|
|
289
330
|
if (tMin !== Infinity && tMax !== -Infinity) {
|
|
290
331
|
const rawRange = tMax - tMin;
|
|
@@ -215,6 +215,12 @@ export function tickLiveChartSeriesEngineFrame(
|
|
|
215
215
|
else hi = mid;
|
|
216
216
|
}
|
|
217
217
|
for (let j = lo; j < points.length; j++) {
|
|
218
|
+
// While scrolled back, stop at the frozen right edge so newer points
|
|
219
|
+
// don't inflate the visible Y range (the per-series tips folded below
|
|
220
|
+
// already track the edge value, so they stay in-range). Following live,
|
|
221
|
+
// keep the tail inclusive — feed timestamps can run slightly ahead of
|
|
222
|
+
// the local clock and must not flicker out of the range.
|
|
223
|
+
if (scrolledBack && points[j].time > state.timestamp) break;
|
|
218
224
|
const v = points[j].value;
|
|
219
225
|
/* istanbul ignore next -- trivial min/max */
|
|
220
226
|
if (v < tMin) tMin = v;
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
LineStyleConfig,
|
|
15
15
|
LiveChartMetrics,
|
|
16
16
|
LiveChartMetricsOverride,
|
|
17
|
+
LiveChartPoint,
|
|
17
18
|
LoadingConfig,
|
|
18
19
|
MarkerClusterConfig,
|
|
19
20
|
DotConfig,
|
|
@@ -326,16 +327,31 @@ export interface ResolvedThresholdLineConfig {
|
|
|
326
327
|
intervals: [number, number];
|
|
327
328
|
strokeWidth: number;
|
|
328
329
|
showValue: boolean;
|
|
330
|
+
/** undefined → fall back to `color`, then palette.refLabel at render time. */
|
|
331
|
+
labelColor: string | undefined;
|
|
329
332
|
}
|
|
330
333
|
|
|
331
334
|
export interface ResolvedThresholdConfig {
|
|
332
|
-
/**
|
|
333
|
-
|
|
335
|
+
/**
|
|
336
|
+
* The split value (Y-axis units). A `SharedValue<number>` for a single live
|
|
337
|
+
* benchmark (read on the UI thread each frame), or a `LiveChartPoint[]` for a
|
|
338
|
+
* time-varying threshold the split follows point-for-point. `undefined` when
|
|
339
|
+
* {@link series} carries the threshold instead.
|
|
340
|
+
*/
|
|
341
|
+
value: SharedValue<number> | LiveChartPoint[] | undefined;
|
|
342
|
+
/** Live time-varying threshold (`SharedValue<LiveChartPoint[]>`); wins over `value`. */
|
|
343
|
+
series: SharedValue<LiveChartPoint[]> | null;
|
|
334
344
|
/** undefined → use palette.candleUp (up-green) at render time. */
|
|
335
345
|
aboveColor: string | undefined;
|
|
336
346
|
/** undefined → use palette.candleDown (down-red) at render time. */
|
|
337
347
|
belowColor: string | undefined;
|
|
338
348
|
fill: boolean;
|
|
349
|
+
/** Band opacity (0–1); {@link THRESHOLD_FILL_OPACITY_DEFAULT} unless tuned. */
|
|
350
|
+
fillOpacity: number;
|
|
351
|
+
/** Fold the threshold into the Y-axis range fit. */
|
|
352
|
+
includeInRange: boolean;
|
|
353
|
+
/** Series forms: extend flat past the last point to "now". */
|
|
354
|
+
extendToNow: boolean;
|
|
339
355
|
line: ResolvedThresholdLineConfig | null;
|
|
340
356
|
}
|
|
341
357
|
|
|
@@ -346,8 +362,12 @@ const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
|
|
|
346
362
|
intervals: [4, 4],
|
|
347
363
|
strokeWidth: 1,
|
|
348
364
|
showValue: false,
|
|
365
|
+
labelColor: undefined,
|
|
349
366
|
};
|
|
350
367
|
|
|
368
|
+
/** Default threshold band opacity — matches reference-line bands. */
|
|
369
|
+
export const THRESHOLD_FILL_OPACITY_DEFAULT = 0.16;
|
|
370
|
+
|
|
351
371
|
/**
|
|
352
372
|
* Resolves the `threshold.line` sub-prop to a config or null (no marker line).
|
|
353
373
|
* `true` → dashed defaults, object → merged, falsy/undefined → null.
|
|
@@ -360,18 +380,24 @@ export function resolveThresholdLine(
|
|
|
360
380
|
|
|
361
381
|
/**
|
|
362
382
|
* Resolves the `threshold` prop to a fully-typed config or null (disabled).
|
|
363
|
-
* Presence-gated (like `referenceLines`/`markers`):
|
|
364
|
-
*
|
|
383
|
+
* Presence-gated (like `referenceLines`/`markers`): a config object with a
|
|
384
|
+
* `value` or `series` enables it — there is no boolean form.
|
|
365
385
|
*/
|
|
366
386
|
export function resolveThreshold(
|
|
367
387
|
prop: ThresholdConfig | undefined,
|
|
368
388
|
): ResolvedThresholdConfig | null {
|
|
369
|
-
if (!prop) return null;
|
|
389
|
+
if (!prop || (prop.value == null && prop.series == null)) return null;
|
|
370
390
|
return {
|
|
371
391
|
value: prop.value,
|
|
392
|
+
series: prop.series ?? null,
|
|
372
393
|
aboveColor: prop.aboveColor,
|
|
373
394
|
belowColor: prop.belowColor,
|
|
374
|
-
fill: prop.fill
|
|
395
|
+
fill: !!prop.fill,
|
|
396
|
+
fillOpacity:
|
|
397
|
+
(typeof prop.fill === "object" ? prop.fill.opacity : undefined) ??
|
|
398
|
+
THRESHOLD_FILL_OPACITY_DEFAULT,
|
|
399
|
+
includeInRange: prop.includeInRange ?? false,
|
|
400
|
+
extendToNow: prop.extendToNow ?? true,
|
|
375
401
|
line: resolveThresholdLine(prop.line),
|
|
376
402
|
};
|
|
377
403
|
}
|
|
@@ -37,6 +37,17 @@ export interface EngineConfig {
|
|
|
37
37
|
* `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts.
|
|
38
38
|
*/
|
|
39
39
|
liveReferenceValues?: SharedValue<number[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Time-varying threshold series folded into the axis-range fit
|
|
42
|
+
* (`threshold.includeInRange`): each tick contributes the series' min/max over
|
|
43
|
+
* the visible window — like {@link referenceValues}, but windowed. A
|
|
44
|
+
* `SharedValue` for the live `threshold.series` form, a plain array for the
|
|
45
|
+
* `LiveChartPoint[]` form.
|
|
46
|
+
*/
|
|
47
|
+
thresholdRangePoints?: SharedValue<LiveChartPoint[]> | LiveChartPoint[];
|
|
48
|
+
/** With {@link thresholdRangePoints}: whether the series extends flat past its
|
|
49
|
+
* last point to "now" (`threshold.extendToNow`). Default `true`. */
|
|
50
|
+
thresholdRangeExtendToNow?: boolean;
|
|
40
51
|
nonNegative?: boolean;
|
|
41
52
|
maxValue?: number;
|
|
42
53
|
nowOverride?: number;
|
|
@@ -183,6 +194,9 @@ export interface EngineFrameRefs {
|
|
|
183
194
|
exaggerateSV: SharedValue<boolean>;
|
|
184
195
|
referenceValue: SharedValue<number | undefined>;
|
|
185
196
|
referenceValues?: SharedValue<number[] | undefined>;
|
|
197
|
+
/** Series threshold folded into the range fit (`threshold.includeInRange`). */
|
|
198
|
+
thresholdRangePoints?: SharedValue<LiveChartPoint[] | undefined>;
|
|
199
|
+
thresholdRangeExtendToNow?: SharedValue<boolean>;
|
|
186
200
|
nonNegativeSV?: SharedValue<boolean>;
|
|
187
201
|
maxValueSV?: SharedValue<number | undefined>;
|
|
188
202
|
nowOverrideSV?: SharedValue<number | undefined>;
|
|
@@ -251,6 +265,8 @@ export function applyLiveChartEngineFrame(
|
|
|
251
265
|
exaggerate: sv.exaggerateSV.value,
|
|
252
266
|
referenceValue: sv.referenceValue.value,
|
|
253
267
|
referenceValues: sv.referenceValues?.value,
|
|
268
|
+
thresholdRangePoints: sv.thresholdRangePoints?.value,
|
|
269
|
+
thresholdRangeExtendToNow: sv.thresholdRangeExtendToNow?.value ?? true,
|
|
254
270
|
nonNegative: sv.nonNegativeSV?.value ?? false,
|
|
255
271
|
maxValue: sv.maxValueSV?.value,
|
|
256
272
|
nowOverride: sv.nowOverrideSV?.value,
|
|
@@ -325,6 +341,23 @@ export function useLiveChartEngine(
|
|
|
325
341
|
if (!live || live.length === 0) return base;
|
|
326
342
|
return base && base.length > 0 ? base.concat(live) : live;
|
|
327
343
|
});
|
|
344
|
+
// Threshold range fold (`threshold.includeInRange`, series forms). Captured
|
|
345
|
+
// directly (not via `config.`) like `liveReferenceValues`, so the live
|
|
346
|
+
// `threshold.series` SharedValue is tracked and the tick sees fresh points.
|
|
347
|
+
const thresholdRangeSourceSV =
|
|
348
|
+
config.thresholdRangePoints !== undefined &&
|
|
349
|
+
!Array.isArray(config.thresholdRangePoints)
|
|
350
|
+
? config.thresholdRangePoints
|
|
351
|
+
: null;
|
|
352
|
+
const thresholdRangePoints = useDerivedValue<LiveChartPoint[] | undefined>(
|
|
353
|
+
() =>
|
|
354
|
+
thresholdRangeSourceSV
|
|
355
|
+
? thresholdRangeSourceSV.value
|
|
356
|
+
: (config.thresholdRangePoints as LiveChartPoint[] | undefined),
|
|
357
|
+
);
|
|
358
|
+
const thresholdRangeExtendToNow = useDerivedValue(
|
|
359
|
+
() => config.thresholdRangeExtendToNow ?? true,
|
|
360
|
+
);
|
|
328
361
|
const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
|
|
329
362
|
const maxValueSV = useDerivedValue(() => config.maxValue);
|
|
330
363
|
const nowOverrideSV = useDerivedValue(() => config.nowOverride);
|
|
@@ -412,6 +445,8 @@ export function useLiveChartEngine(
|
|
|
412
445
|
exaggerateSV,
|
|
413
446
|
referenceValue,
|
|
414
447
|
referenceValues,
|
|
448
|
+
thresholdRangePoints,
|
|
449
|
+
thresholdRangeExtendToNow,
|
|
415
450
|
nonNegativeSV,
|
|
416
451
|
maxValueSV,
|
|
417
452
|
nowOverrideSV,
|
|
@@ -4,6 +4,7 @@ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
|
4
4
|
import type { SingleEngineState } from "../core/useLiveChartEngine";
|
|
5
5
|
import { buildLinePoints, type ChartPadding } from "../draw/line";
|
|
6
6
|
import { drawSpline, makeSplineScratch } from "../math/spline";
|
|
7
|
+
import { sampleThresholdYAt, thresholdSampleSpanX } from "../math/threshold";
|
|
7
8
|
import { blendPtsY, squigglifyPts } from "../math/squiggly";
|
|
8
9
|
import { usePathBuilder } from "./usePathBuilder";
|
|
9
10
|
|
|
@@ -39,6 +40,11 @@ export function useChartPaths(
|
|
|
39
40
|
squiggleAmplitude = 14,
|
|
40
41
|
/** Loading squiggle wave speed multiplier for the reveal morph. Default 1. */
|
|
41
42
|
squiggleSpeed = 1,
|
|
43
|
+
/** When set, build `thresholdFillPath` as the band between the line and this
|
|
44
|
+
* *time-varying* threshold — the split shader's evenly-spaced pixel-Y
|
|
45
|
+
* `samples[]` (so band geometry matches the shader exactly). Takes precedence
|
|
46
|
+
* over `thresholdY`, the constant (horizontal) case. */
|
|
47
|
+
thresholdSamples?: SharedValue<number[]>,
|
|
42
48
|
) {
|
|
43
49
|
const lineBuilder = usePathBuilder();
|
|
44
50
|
const fillBuilder = usePathBuilder();
|
|
@@ -134,17 +140,55 @@ export function useChartPaths(
|
|
|
134
140
|
return b.detach();
|
|
135
141
|
});
|
|
136
142
|
|
|
137
|
-
// Threshold-anchored fill: the same spline, closed
|
|
138
|
-
// baseline, so the band lies between the line and the threshold (the
|
|
139
|
-
// area). Painted with the
|
|
140
|
-
// shows the above-color and the part below shows the below-color.
|
|
143
|
+
// Threshold-anchored fill: the same spline, closed along the threshold instead
|
|
144
|
+
// of the baseline, so the band lies between the line and the threshold (the
|
|
145
|
+
// profit/loss area). Painted with the split gradient/shader, the part above the
|
|
146
|
+
// split shows the above-color and the part below shows the below-color.
|
|
147
|
+
//
|
|
148
|
+
// `thresholdPts` (a time-varying series) closes the band along that polyline,
|
|
149
|
+
// right-to-left; otherwise `thresholdY` closes it at a single horizontal Y.
|
|
141
150
|
const thresholdFillPath = useDerivedValue(() => {
|
|
142
151
|
const cache = cacheRef.current!;
|
|
143
|
-
if (!thresholdY) return cache.emptyPath;
|
|
144
|
-
const yT = thresholdY.get();
|
|
145
152
|
const pts = flatPts.get();
|
|
146
153
|
const n = pts.length >> 1;
|
|
147
|
-
if (n < 2
|
|
154
|
+
if (n < 2) return cache.emptyPath;
|
|
155
|
+
|
|
156
|
+
const tsamples = thresholdSamples?.get();
|
|
157
|
+
if (tsamples && tsamples.length >= 2) {
|
|
158
|
+
const b = thresholdFillBuilder.value;
|
|
159
|
+
b.moveTo(pts[0], pts[1]);
|
|
160
|
+
drawSpline(b, pts, cache.scratch, linear);
|
|
161
|
+
// Band bottom = the SAMPLED threshold (identical to what the split shader
|
|
162
|
+
// reads), pinned to the LINE's x-range. Because the geometry and the shader
|
|
163
|
+
// use the same evenly-spaced, linearly-interpolated samples, a step riser
|
|
164
|
+
// ramps the same way in both — no green/red sliver bleeds through — and the
|
|
165
|
+
// x-range pin keeps the band closing with clean vertical sides (no wedge).
|
|
166
|
+
const leftX = pts[0];
|
|
167
|
+
const rightX = pts[(n - 1) * 2];
|
|
168
|
+
const count = tsamples.length;
|
|
169
|
+
// The samples live on the time-anchored, gliding grid — interpolate them
|
|
170
|
+
// across that span (same as the shader), not the static plot edges.
|
|
171
|
+
const [x0, x1] = thresholdSampleSpanX(
|
|
172
|
+
engine.timestamp.get(),
|
|
173
|
+
engine.displayWindow.get(),
|
|
174
|
+
padding.left,
|
|
175
|
+
engine.canvasWidth.get() - padding.right,
|
|
176
|
+
count,
|
|
177
|
+
);
|
|
178
|
+
const step = (x1 - x0) / (count - 1);
|
|
179
|
+
b.lineTo(rightX, sampleThresholdYAt(tsamples, x0, x1, rightX));
|
|
180
|
+
for (let i = count - 1; i >= 0; i--) {
|
|
181
|
+
const sx = x0 + step * i;
|
|
182
|
+
if (sx > leftX && sx < rightX) b.lineTo(sx, tsamples[i]);
|
|
183
|
+
}
|
|
184
|
+
b.lineTo(leftX, sampleThresholdYAt(tsamples, x0, x1, leftX));
|
|
185
|
+
b.close();
|
|
186
|
+
return b.detach();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!thresholdY) return cache.emptyPath;
|
|
190
|
+
const yT = thresholdY.get();
|
|
191
|
+
if (!Number.isFinite(yT)) return cache.emptyPath;
|
|
148
192
|
const b = thresholdFillBuilder.value;
|
|
149
193
|
b.moveTo(pts[0], pts[1]);
|
|
150
194
|
drawSpline(b, pts, cache.scratch, linear);
|
|
@@ -3,14 +3,41 @@ import {
|
|
|
3
3
|
Skia,
|
|
4
4
|
useFont,
|
|
5
5
|
type SkFont,
|
|
6
|
+
type SkFontMgr,
|
|
6
7
|
} from "@shopify/react-native-skia";
|
|
7
8
|
|
|
8
9
|
import { resolveFontConfig } from "../core/resolveConfig";
|
|
9
10
|
import type { FontConfig } from "../types";
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* System-font match cache. `matchFont` walks the platform font manager to
|
|
14
|
+
* resolve a typeface — a few ms per call — and every chart resolves several
|
|
15
|
+
* fonts per render, so a screen full of sparklines would otherwise re-match
|
|
16
|
+
* the same `{family, size, weight}` dozens of times. `SkFont` instances are
|
|
17
|
+
* immutable and safe to share across canvases. Custom `fontManager`s bypass
|
|
18
|
+
* the cache (their identity isn't part of the key).
|
|
19
|
+
*/
|
|
20
|
+
const systemFontCache = new Map<string, SkFont>();
|
|
21
|
+
let systemFontMgr: SkFontMgr | null = null;
|
|
22
|
+
|
|
23
|
+
function matchSystemFont(
|
|
24
|
+
fontFamily: string,
|
|
25
|
+
fontSize: number,
|
|
26
|
+
fontWeight: NonNullable<FontConfig["fontWeight"]>,
|
|
27
|
+
): SkFont {
|
|
28
|
+
const key = `${fontFamily}|${fontSize}|${fontWeight}`;
|
|
29
|
+
const cached = systemFontCache.get(key);
|
|
30
|
+
if (cached) return cached;
|
|
31
|
+
systemFontMgr ??= Skia.FontMgr.System();
|
|
32
|
+
const font = matchFont({ fontFamily, fontSize, fontWeight }, systemFontMgr);
|
|
33
|
+
systemFontCache.set(key, font);
|
|
34
|
+
return font;
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
/**
|
|
12
38
|
* Resolves Skia text for charts: optional bundled `typeface` via `useFont`, otherwise
|
|
13
|
-
* `matchFont` with optional custom `fontManager`.
|
|
39
|
+
* `matchFont` with optional custom `fontManager`. System-font matches are cached
|
|
40
|
+
* module-wide, so many charts sharing a family/size/weight resolve it once.
|
|
14
41
|
*/
|
|
15
42
|
export function useChartSkiaFont(
|
|
16
43
|
fontProp: FontConfig | undefined,
|
|
@@ -22,10 +49,9 @@ export function useChartSkiaFont(
|
|
|
22
49
|
const typefaceSource = fontProp?.typeface ?? null;
|
|
23
50
|
const customFont = useFont(typefaceSource, fontSize);
|
|
24
51
|
|
|
25
|
-
const fallbackFont =
|
|
26
|
-
{ fontFamily, fontSize, fontWeight },
|
|
27
|
-
|
|
28
|
-
);
|
|
52
|
+
const fallbackFont = fontProp?.fontManager
|
|
53
|
+
? matchFont({ fontFamily, fontSize, fontWeight }, fontProp.fontManager)
|
|
54
|
+
: matchSystemFont(fontFamily, fontSize, fontWeight);
|
|
29
55
|
|
|
30
56
|
if (typefaceSource != null) {
|
|
31
57
|
return customFont ?? fallbackFont;
|