react-native-livechart 1.0.0 → 2.0.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 +15 -3
- package/dist/components/CrosshairLine.d.ts +6 -1
- package/dist/components/CrosshairLine.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +7 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +12 -5
- package/dist/components/DotOverlay.d.ts.map +1 -1
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/LoadingOverlay.d.ts +6 -2
- package/dist/components/LoadingOverlay.d.ts.map +1 -1
- package/dist/components/MarkerOverlay.d.ts +8 -4
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +8 -2
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +4 -2
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/constants.d.ts +16 -13
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +2 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +2 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +34 -4
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +3 -0
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +3 -0
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/candle.d.ts +2 -2
- package/dist/draw/candle.d.ts.map +1 -1
- package/dist/draw/grid.d.ts +2 -1
- package/dist/draw/grid.d.ts.map +1 -1
- package/dist/draw/line.d.ts +8 -8
- package/dist/draw/line.d.ts.map +1 -1
- package/dist/draw/markerAtlas.d.ts +44 -0
- package/dist/draw/markerAtlas.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +12 -4
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/resolveChartLayout.d.ts +3 -1
- package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
- package/dist/hooks/useBadge.d.ts +2 -2
- package/dist/hooks/useBadge.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +2 -2
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +3 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useCrosshairSeries.d.ts +3 -1
- package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
- package/dist/hooks/useYAxis.d.ts +2 -1
- package/dist/hooks/useYAxis.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +136 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CrosshairLine.tsx +30 -10
- package/src/components/CrosshairOverlay.tsx +31 -10
- package/src/components/DotOverlay.tsx +29 -14
- package/src/components/LiveChart.tsx +67 -15
- package/src/components/LiveChartSeries.tsx +76 -17
- package/src/components/LoadingOverlay.tsx +20 -11
- package/src/components/MarkerOverlay.tsx +119 -164
- package/src/components/MultiSeriesDots.tsx +30 -2
- package/src/components/YAxisOverlay.tsx +8 -4
- package/src/constants.ts +54 -14
- package/src/core/liveChartEngineTick.ts +7 -2
- package/src/core/liveChartSeriesEngineTick.ts +8 -2
- package/src/core/resolveConfig.ts +137 -38
- package/src/core/useLiveChartEngine.ts +6 -0
- package/src/core/useLiveChartSeriesEngine.ts +6 -0
- package/src/draw/candle.ts +11 -6
- package/src/draw/grid.ts +5 -5
- package/src/draw/line.ts +106 -25
- package/src/draw/markerAtlas.ts +304 -0
- package/src/hooks/crosshairShared.ts +24 -3
- package/src/hooks/resolveChartLayout.ts +11 -2
- package/src/hooks/useBadge.ts +38 -24
- package/src/hooks/useCandlePaths.ts +4 -2
- package/src/hooks/useCrosshair.ts +15 -2
- package/src/hooks/useCrosshairSeries.ts +8 -2
- package/src/hooks/useYAxis.ts +4 -1
- package/src/index.ts +7 -0
- package/src/types.ts +147 -5
package/src/draw/line.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
BADGE_MARGIN_RIGHT,
|
|
4
|
-
BADGE_PILL_PAD_X,
|
|
5
|
-
BADGE_PILL_PAD_Y,
|
|
6
|
-
BADGE_TAIL_LEN,
|
|
7
|
-
} from "../constants";
|
|
8
|
-
import type { ChartInsets, LiveChartPoint } from "../types";
|
|
1
|
+
import { BADGE_METRICS_DEFAULTS } from "../constants";
|
|
2
|
+
import type { BadgeMetrics, ChartInsets, LiveChartPoint } from "../types";
|
|
9
3
|
export {
|
|
10
4
|
BADGE_DOT_GAP,
|
|
11
5
|
BADGE_MARGIN_RIGHT,
|
|
@@ -36,10 +30,14 @@ export const DEFAULT_PADDING: ChartPadding = {
|
|
|
36
30
|
* When `showTail` is false the tail spike is omitted and only the round cap
|
|
37
31
|
* radius is returned, letting callers shrink the right gutter.
|
|
38
32
|
*/
|
|
39
|
-
export function badgeTailAndCap(
|
|
33
|
+
export function badgeTailAndCap(
|
|
34
|
+
fontSize: number,
|
|
35
|
+
showTail = true,
|
|
36
|
+
badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
37
|
+
): number {
|
|
40
38
|
"worklet";
|
|
41
|
-
const pillH = fontSize +
|
|
42
|
-
return (showTail ?
|
|
39
|
+
const pillH = fontSize + badge.padY * 2;
|
|
40
|
+
return (showTail ? badge.tailLength : 0) + pillH / 2;
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
/**
|
|
@@ -56,10 +54,11 @@ export function pillTextLeftX(
|
|
|
56
54
|
paddingRight: number,
|
|
57
55
|
tl: number,
|
|
58
56
|
textWidth: number,
|
|
57
|
+
badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
59
58
|
): number {
|
|
60
59
|
"worklet";
|
|
61
60
|
const bodyLeft = canvasWidth - paddingRight + tl;
|
|
62
|
-
const bodyRight = canvasWidth -
|
|
61
|
+
const bodyRight = canvasWidth - badge.marginEdge;
|
|
63
62
|
return (bodyLeft + bodyRight - textWidth) / 2;
|
|
64
63
|
}
|
|
65
64
|
|
|
@@ -98,10 +97,11 @@ export function minPaddingRightForBadgeYAxisAlign(
|
|
|
98
97
|
fontSize: number,
|
|
99
98
|
textWidth: number,
|
|
100
99
|
showTail = true,
|
|
100
|
+
badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
101
101
|
): number {
|
|
102
|
-
const tl = badgeTailAndCap(fontSize, showTail);
|
|
102
|
+
const tl = badgeTailAndCap(fontSize, showTail, badge);
|
|
103
103
|
return Math.ceil(
|
|
104
|
-
|
|
104
|
+
badge.dotGap + tl + 2 * badge.padX + textWidth + badge.marginEdge,
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -110,8 +110,9 @@ export function resolveAutoRight(
|
|
|
110
110
|
yAxis: boolean,
|
|
111
111
|
badge: boolean,
|
|
112
112
|
showTail = true,
|
|
113
|
+
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
113
114
|
): number {
|
|
114
|
-
if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail);
|
|
115
|
+
if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics);
|
|
115
116
|
if (yAxis) return 44;
|
|
116
117
|
return DEFAULT_PADDING.right;
|
|
117
118
|
}
|
|
@@ -120,15 +121,21 @@ export function resolveAutoRight(
|
|
|
120
121
|
* Minimum `padding.left` for a badge pill drawn in the left chart margin (label width + horizontal padding + dot gap).
|
|
121
122
|
* `resolveChartLayout` does not call this; it remains available for custom layouts or `resolvePadding(..., badgeOnLeft: true)`.
|
|
122
123
|
*/
|
|
123
|
-
export function minPaddingLeftForBadge(
|
|
124
|
+
export function minPaddingLeftForBadge(
|
|
125
|
+
textWidth: number,
|
|
126
|
+
badge: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
127
|
+
): number {
|
|
124
128
|
return Math.ceil(
|
|
125
|
-
|
|
129
|
+
badge.marginEdge + 2 * badge.padX + textWidth + badge.dotGap,
|
|
126
130
|
);
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
/** Default left inset, or a wider inset when `badgeOnLeft` is true (see `minPaddingLeftForBadge`). */
|
|
130
|
-
export function resolveAutoLeft(
|
|
131
|
-
|
|
134
|
+
export function resolveAutoLeft(
|
|
135
|
+
badgeOnLeft: boolean,
|
|
136
|
+
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
137
|
+
): number {
|
|
138
|
+
if (badgeOnLeft) return minPaddingLeftForBadge(49, badgeMetrics);
|
|
132
139
|
return DEFAULT_PADDING.left;
|
|
133
140
|
}
|
|
134
141
|
|
|
@@ -171,9 +178,15 @@ export function resolvePadding(
|
|
|
171
178
|
badgeOnLeft = false,
|
|
172
179
|
xAxis = true,
|
|
173
180
|
showTail = true,
|
|
181
|
+
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
174
182
|
): ChartPadding {
|
|
175
|
-
const autoRight = resolveAutoRight(
|
|
176
|
-
|
|
183
|
+
const autoRight = resolveAutoRight(
|
|
184
|
+
yAxis,
|
|
185
|
+
badge && !badgeOnLeft,
|
|
186
|
+
showTail,
|
|
187
|
+
badgeMetrics,
|
|
188
|
+
);
|
|
189
|
+
const autoLeft = resolveAutoLeft(badgeOnLeft, badgeMetrics);
|
|
177
190
|
const autoBottom = xAxis ? DEFAULT_PADDING.bottom : 8;
|
|
178
191
|
if (!override) {
|
|
179
192
|
return {
|
|
@@ -239,12 +252,80 @@ export function buildLinePoints(
|
|
|
239
252
|
}
|
|
240
253
|
const startIdx = Math.max(0, lo - 1);
|
|
241
254
|
|
|
242
|
-
|
|
243
|
-
|
|
255
|
+
// End of the visible range: first index strictly after `now` (upper bound).
|
|
256
|
+
let elo = startIdx;
|
|
257
|
+
let ehi = data.length;
|
|
258
|
+
while (elo < ehi) {
|
|
259
|
+
const emid = (elo + ehi) >> 1;
|
|
260
|
+
if (data[emid].time <= now) elo = emid + 1;
|
|
261
|
+
else ehi = emid;
|
|
262
|
+
}
|
|
263
|
+
const endIdx = elo;
|
|
264
|
+
|
|
265
|
+
const xScale = chartW / windowSecs;
|
|
266
|
+
const yScale = chartH / valRange;
|
|
267
|
+
|
|
268
|
+
// Decimate to ~2 points per horizontal pixel once the window is denser than
|
|
269
|
+
// that. Drawing more points than the canvas is wide is wasted per-frame work
|
|
270
|
+
// (array build + Skia stroke) that scales with sample count rather than
|
|
271
|
+
// pixels — the dominant cost on dense / wide-window charts, and the reason
|
|
272
|
+
// scrubbing a saturated window drops frames. Below the threshold we keep the
|
|
273
|
+
// exact per-sample path (and existing behaviour) untouched.
|
|
274
|
+
const maxPlainPoints = Math.ceil(chartW * 2);
|
|
275
|
+
|
|
276
|
+
if (endIdx - startIdx <= maxPlainPoints) {
|
|
277
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
278
|
+
pts.push(
|
|
279
|
+
padding.left + (data[i].time - winStart) * xScale,
|
|
280
|
+
padding.top + (displayMax - data[i].value) * yScale,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// Min/max-per-pixel-column decimation: within each pixel column keep the
|
|
285
|
+
// lowest- and highest-value samples (emitted in their original time order)
|
|
286
|
+
// so the line's envelope and any volatility spikes survive at full vertical
|
|
287
|
+
// fidelity while the point count stays bounded by the canvas width.
|
|
288
|
+
let curCol = -2147483648;
|
|
289
|
+
let minIdx = startIdx;
|
|
290
|
+
let maxIdx = startIdx;
|
|
291
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
292
|
+
const col = ((data[i].time - winStart) * xScale) | 0;
|
|
293
|
+
if (col !== curCol) {
|
|
294
|
+
if (curCol !== -2147483648) {
|
|
295
|
+
const a = minIdx <= maxIdx ? minIdx : maxIdx;
|
|
296
|
+
const b = minIdx <= maxIdx ? maxIdx : minIdx;
|
|
297
|
+
pts.push(
|
|
298
|
+
padding.left + (data[a].time - winStart) * xScale,
|
|
299
|
+
padding.top + (displayMax - data[a].value) * yScale,
|
|
300
|
+
);
|
|
301
|
+
if (b !== a) {
|
|
302
|
+
pts.push(
|
|
303
|
+
padding.left + (data[b].time - winStart) * xScale,
|
|
304
|
+
padding.top + (displayMax - data[b].value) * yScale,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
curCol = col;
|
|
309
|
+
minIdx = i;
|
|
310
|
+
maxIdx = i;
|
|
311
|
+
} else {
|
|
312
|
+
if (data[i].value < data[minIdx].value) minIdx = i;
|
|
313
|
+
if (data[i].value > data[maxIdx].value) maxIdx = i;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Flush the final column.
|
|
317
|
+
const a = minIdx <= maxIdx ? minIdx : maxIdx;
|
|
318
|
+
const b = minIdx <= maxIdx ? maxIdx : minIdx;
|
|
244
319
|
pts.push(
|
|
245
|
-
padding.left + (
|
|
246
|
-
padding.top + (
|
|
320
|
+
padding.left + (data[a].time - winStart) * xScale,
|
|
321
|
+
padding.top + (displayMax - data[a].value) * yScale,
|
|
247
322
|
);
|
|
323
|
+
if (b !== a) {
|
|
324
|
+
pts.push(
|
|
325
|
+
padding.left + (data[b].time - winStart) * xScale,
|
|
326
|
+
padding.top + (displayMax - data[b].value) * yScale,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
248
329
|
}
|
|
249
330
|
|
|
250
331
|
// Live tip at current time with smoothed value
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Skia,
|
|
3
|
+
PaintStyle,
|
|
4
|
+
StrokeCap,
|
|
5
|
+
StrokeJoin,
|
|
6
|
+
drawAsImageFromPicture,
|
|
7
|
+
type SkCanvas,
|
|
8
|
+
type SkFont,
|
|
9
|
+
type SkImage,
|
|
10
|
+
type SkPaint,
|
|
11
|
+
type SkRect,
|
|
12
|
+
} from "@shopify/react-native-skia";
|
|
13
|
+
import type { LiveChartPalette, Marker, MarkerKind } from "../types";
|
|
14
|
+
|
|
15
|
+
/** Default icon box (px) when `marker.size` is unset. */
|
|
16
|
+
export const DEFAULT_ICON_SIZE = 16;
|
|
17
|
+
/** Circular badge padding around the icon glyph + background ring width. */
|
|
18
|
+
const PILL_PAD = 2;
|
|
19
|
+
const PILL_BORDER = 2;
|
|
20
|
+
const PILL_TEXT_COLOR = "#ffffff";
|
|
21
|
+
/** Anti-alias breathing room baked around every cell so sprites don't clip. */
|
|
22
|
+
const CELL_MARGIN = 2;
|
|
23
|
+
|
|
24
|
+
/** Default glyph color per kind when `marker.color` is unset. */
|
|
25
|
+
export function defaultMarkerColor(
|
|
26
|
+
kind: MarkerKind,
|
|
27
|
+
palette: LiveChartPalette,
|
|
28
|
+
): string {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case "trade":
|
|
31
|
+
return palette.line;
|
|
32
|
+
case "boost":
|
|
33
|
+
return palette.refLine;
|
|
34
|
+
case "graduation":
|
|
35
|
+
return palette.dotUp;
|
|
36
|
+
case "winner":
|
|
37
|
+
return palette.dotUp;
|
|
38
|
+
case "clawback":
|
|
39
|
+
return palette.refLabel;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Axis-anchored kinds whose geometry depends on the chart's baseline (a
|
|
45
|
+
* variable-length stem, or a box pinned to the axis) and therefore cannot be a
|
|
46
|
+
* fixed-size sprite. These fall back to a self-projecting glyph component.
|
|
47
|
+
*
|
|
48
|
+
* Only applies when the marker has no `icon`/`image` override — those take
|
|
49
|
+
* precedence and render as ordinary centered stamps via the atlas.
|
|
50
|
+
*/
|
|
51
|
+
export function isConnectorMarker(m: Marker): boolean {
|
|
52
|
+
"worklet";
|
|
53
|
+
return (
|
|
54
|
+
!m.image &&
|
|
55
|
+
!m.icon &&
|
|
56
|
+
(m.kind === "graduation" || m.kind === "clawback")
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stable key for a marker's *visual appearance* (excludes position/anchor).
|
|
62
|
+
* Two markers with the same signature share one atlas cell. Called both on the
|
|
63
|
+
* JS thread (to build the atlas) and inside the per-frame worklet (to look a
|
|
64
|
+
* marker's cell up), so it must stay worklet-safe and primitive-only.
|
|
65
|
+
*/
|
|
66
|
+
export function markerAppearanceSig(m: Marker): string {
|
|
67
|
+
"worklet";
|
|
68
|
+
if (m.image) return `img\x1f${m.id}\x1f${m.size ?? DEFAULT_ICON_SIZE}`;
|
|
69
|
+
const size = m.size ?? "";
|
|
70
|
+
if (m.icon)
|
|
71
|
+
return `ic\x1f${m.icon}\x1f${m.pill ? 1 : 0}\x1f${m.color ?? ""}\x1f${size}`;
|
|
72
|
+
return `k\x1f${m.kind}\x1f${m.color ?? ""}\x1f${size}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One packed glyph in the atlas image: its source rect + box size. */
|
|
76
|
+
export interface AtlasCell {
|
|
77
|
+
rect: SkRect;
|
|
78
|
+
w: number;
|
|
79
|
+
h: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MarkerAtlas {
|
|
83
|
+
/** Packed glyph texture, or null when there are no atlas-rendered markers. */
|
|
84
|
+
image: SkImage | null;
|
|
85
|
+
/** appearance-signature → cell. */
|
|
86
|
+
cells: Record<string, AtlasCell>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const EMPTY_ATLAS: MarkerAtlas = { image: null, cells: {} };
|
|
90
|
+
|
|
91
|
+
function fillPaint(color: string): SkPaint {
|
|
92
|
+
const p = Skia.Paint();
|
|
93
|
+
p.setAntiAlias(true);
|
|
94
|
+
p.setColor(Skia.Color(color));
|
|
95
|
+
p.setStyle(PaintStyle.Fill);
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function strokePaint(color: string, width: number, round = false): SkPaint {
|
|
100
|
+
const p = Skia.Paint();
|
|
101
|
+
p.setAntiAlias(true);
|
|
102
|
+
p.setColor(Skia.Color(color));
|
|
103
|
+
p.setStyle(PaintStyle.Stroke);
|
|
104
|
+
p.setStrokeWidth(width);
|
|
105
|
+
if (round) {
|
|
106
|
+
p.setStrokeCap(StrokeCap.Round);
|
|
107
|
+
p.setStrokeJoin(StrokeJoin.Round);
|
|
108
|
+
}
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CellSpec {
|
|
113
|
+
w: number;
|
|
114
|
+
h: number;
|
|
115
|
+
/** Draw the glyph centered at (cx, cy) in the recording canvas. */
|
|
116
|
+
draw: (canvas: SkCanvas, cx: number, cy: number) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Geometry + draw routine for one marker appearance. Mirrors the per-glyph
|
|
121
|
+
* rendering the old `MarkerGlyph` did inline, so the atlas is pixel-equivalent.
|
|
122
|
+
*/
|
|
123
|
+
function cellSpec(m: Marker, palette: LiveChartPalette, font: SkFont): CellSpec {
|
|
124
|
+
const color = m.color ?? defaultMarkerColor(m.kind, palette);
|
|
125
|
+
const m2 = CELL_MARGIN * 2;
|
|
126
|
+
|
|
127
|
+
if (m.image) {
|
|
128
|
+
const img = m.image;
|
|
129
|
+
const size = m.size ?? DEFAULT_ICON_SIZE;
|
|
130
|
+
const imgPaint = Skia.Paint();
|
|
131
|
+
imgPaint.setAntiAlias(true);
|
|
132
|
+
return {
|
|
133
|
+
w: size + m2,
|
|
134
|
+
h: size + m2,
|
|
135
|
+
draw: (canvas, cx, cy) => {
|
|
136
|
+
const iw = img.width();
|
|
137
|
+
const ih = img.height();
|
|
138
|
+
// `contain` fit: scale to fit the box without distorting aspect.
|
|
139
|
+
const scale = iw > 0 && ih > 0 ? Math.min(size / iw, size / ih) : 1;
|
|
140
|
+
const dw = iw * scale;
|
|
141
|
+
const dh = ih * scale;
|
|
142
|
+
canvas.drawImageRect(
|
|
143
|
+
img,
|
|
144
|
+
Skia.XYWHRect(0, 0, iw, ih),
|
|
145
|
+
Skia.XYWHRect(cx - dw / 2, cy - dh / 2, dw, dh),
|
|
146
|
+
imgPaint,
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (m.icon) {
|
|
153
|
+
const icon = m.icon;
|
|
154
|
+
const b = font.measureText(icon);
|
|
155
|
+
// Center the glyph on its measured bounds (tighter than ascent/descent).
|
|
156
|
+
const iconDX = b.x + b.width / 2;
|
|
157
|
+
const iconDY = b.y + b.height / 2;
|
|
158
|
+
|
|
159
|
+
if (m.pill) {
|
|
160
|
+
const bgColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
|
|
161
|
+
const pillR = Math.max(b.width, b.height) / 2 + PILL_PAD;
|
|
162
|
+
const ringR = pillR + PILL_BORDER;
|
|
163
|
+
const box = Math.ceil(2 * ringR) + m2;
|
|
164
|
+
return {
|
|
165
|
+
w: box,
|
|
166
|
+
h: box,
|
|
167
|
+
draw: (canvas, cx, cy) => {
|
|
168
|
+
canvas.drawCircle(cx, cy, ringR, fillPaint(bgColor));
|
|
169
|
+
canvas.drawCircle(cx, cy, pillR, fillPaint(color));
|
|
170
|
+
canvas.drawText(
|
|
171
|
+
icon,
|
|
172
|
+
cx - iconDX,
|
|
173
|
+
cy - iconDY,
|
|
174
|
+
fillPaint(PILL_TEXT_COLOR),
|
|
175
|
+
font,
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
w: Math.ceil(Math.max(b.width, 1)) + m2,
|
|
183
|
+
h: Math.ceil(Math.max(b.height, 1)) + m2,
|
|
184
|
+
draw: (canvas, cx, cy) => {
|
|
185
|
+
canvas.drawText(icon, cx - iconDX, cy - iconDY, fillPaint(color), font);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (m.kind === "trade") {
|
|
191
|
+
const box = 2 * (5 + 1) + m2; // ring r=5 stroked w=2
|
|
192
|
+
return {
|
|
193
|
+
w: box,
|
|
194
|
+
h: box,
|
|
195
|
+
draw: (canvas, cx, cy) => {
|
|
196
|
+
canvas.drawCircle(cx, cy, 5, strokePaint(color, 2));
|
|
197
|
+
canvas.drawCircle(cx, cy, 2, fillPaint(color));
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (m.kind === "winner") {
|
|
203
|
+
const outer = 7;
|
|
204
|
+
const inner = 3;
|
|
205
|
+
const box = 2 * outer + m2;
|
|
206
|
+
return {
|
|
207
|
+
w: box,
|
|
208
|
+
h: box,
|
|
209
|
+
draw: (canvas, cx, cy) => {
|
|
210
|
+
const p = Skia.Path.Make();
|
|
211
|
+
for (let k = 0; k < 10; k++) {
|
|
212
|
+
const ang = -Math.PI / 2 + (k * Math.PI) / 5;
|
|
213
|
+
const rad = k % 2 === 0 ? outer : inner;
|
|
214
|
+
const px = cx + rad * Math.cos(ang);
|
|
215
|
+
const py = cy + rad * Math.sin(ang);
|
|
216
|
+
if (k === 0) p.moveTo(px, py);
|
|
217
|
+
else p.lineTo(px, py);
|
|
218
|
+
}
|
|
219
|
+
p.close();
|
|
220
|
+
canvas.drawPath(p, fillPaint(color));
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (m.kind === "boost") {
|
|
226
|
+
const L = 6;
|
|
227
|
+
const box = 2 * L + m2;
|
|
228
|
+
return {
|
|
229
|
+
w: box,
|
|
230
|
+
h: box,
|
|
231
|
+
draw: (canvas, cx, cy) => {
|
|
232
|
+
const p = Skia.Path.Make();
|
|
233
|
+
for (let k = 0; k < 4; k++) {
|
|
234
|
+
const ang = (k * Math.PI) / 4;
|
|
235
|
+
const dx = L * Math.cos(ang);
|
|
236
|
+
const dy = L * Math.sin(ang);
|
|
237
|
+
p.moveTo(cx - dx, cy - dy);
|
|
238
|
+
p.lineTo(cx + dx, cy + dy);
|
|
239
|
+
}
|
|
240
|
+
canvas.drawPath(p, strokePaint(color, 1.5, true));
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Connector kinds are handled outside the atlas; this is a defensive dot.
|
|
246
|
+
const box = 6 + m2;
|
|
247
|
+
return {
|
|
248
|
+
w: box,
|
|
249
|
+
h: box,
|
|
250
|
+
draw: (canvas, cx, cy) => canvas.drawCircle(cx, cy, 3, fillPaint(color)),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Rasterize every distinct marker appearance into a single packed atlas image,
|
|
256
|
+
* once per appearance-set change (NOT per frame). The per-frame worklet then
|
|
257
|
+
* blits these cells via one `drawAtlas` call.
|
|
258
|
+
*
|
|
259
|
+
* Connector kinds (see `isConnectorMarker`) are skipped — they render via a
|
|
260
|
+
* self-projecting fallback component.
|
|
261
|
+
*/
|
|
262
|
+
export function buildMarkerAtlas(
|
|
263
|
+
markers: Marker[],
|
|
264
|
+
palette: LiveChartPalette,
|
|
265
|
+
font: SkFont,
|
|
266
|
+
): MarkerAtlas {
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
const specs: { sig: string; spec: CellSpec }[] = [];
|
|
269
|
+
for (let i = 0; i < markers.length; i++) {
|
|
270
|
+
const m = markers[i];
|
|
271
|
+
if (isConnectorMarker(m)) continue;
|
|
272
|
+
const sig = markerAppearanceSig(m);
|
|
273
|
+
if (seen.has(sig)) continue;
|
|
274
|
+
seen.add(sig);
|
|
275
|
+
specs.push({ sig, spec: cellSpec(m, palette, font) });
|
|
276
|
+
}
|
|
277
|
+
if (specs.length === 0) return EMPTY_ATLAS;
|
|
278
|
+
|
|
279
|
+
let totalW = 0;
|
|
280
|
+
let maxH = 0;
|
|
281
|
+
for (let i = 0; i < specs.length; i++) {
|
|
282
|
+
totalW += specs[i].spec.w;
|
|
283
|
+
if (specs[i].spec.h > maxH) maxH = specs[i].spec.h;
|
|
284
|
+
}
|
|
285
|
+
const W = Math.max(1, Math.ceil(totalW));
|
|
286
|
+
const H = Math.max(1, Math.ceil(maxH));
|
|
287
|
+
|
|
288
|
+
const recorder = Skia.PictureRecorder();
|
|
289
|
+
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, W, H));
|
|
290
|
+
const cells: Record<string, AtlasCell> = {};
|
|
291
|
+
let x = 0;
|
|
292
|
+
for (let i = 0; i < specs.length; i++) {
|
|
293
|
+
const { sig, spec } = specs[i];
|
|
294
|
+
canvas.save();
|
|
295
|
+
canvas.translate(x, 0);
|
|
296
|
+
spec.draw(canvas, spec.w / 2, H / 2);
|
|
297
|
+
canvas.restore();
|
|
298
|
+
cells[sig] = { rect: Skia.XYWHRect(x, 0, spec.w, H), w: spec.w, h: H };
|
|
299
|
+
x += spec.w;
|
|
300
|
+
}
|
|
301
|
+
const picture = recorder.finishRecordingAsPicture();
|
|
302
|
+
const image = drawAsImageFromPicture(picture, { width: W, height: H });
|
|
303
|
+
return { image, cells };
|
|
304
|
+
}
|
|
@@ -109,6 +109,11 @@ export function computeTooltipLayout(
|
|
|
109
109
|
formatValue: (v: number) => string,
|
|
110
110
|
formatTime: (t: number) => string,
|
|
111
111
|
font: SkFont,
|
|
112
|
+
/** Monospace advance width. When > 0, text width is `len * monoCharWidth`
|
|
113
|
+
* instead of a per-frame Skia `measureText` — scrubbing re-runs this worklet
|
|
114
|
+
* every frame, and `measureText` shapes text each call (a real cost,
|
|
115
|
+
* especially in the simulator). Falls back to `measureText` when 0. */
|
|
116
|
+
monoCharWidth = 0,
|
|
112
117
|
): TooltipLayout {
|
|
113
118
|
"worklet";
|
|
114
119
|
if (!scrubActive || scrubValue === null) return HIDDEN_TOOLTIP;
|
|
@@ -122,8 +127,14 @@ export function computeTooltipLayout(
|
|
|
122
127
|
const lineH = -fm.ascent + fm.descent;
|
|
123
128
|
const totalH = TOOLTIP_PAD_Y * 2 + lineH * 2 + TOOLTIP_LINE_GAP;
|
|
124
129
|
|
|
125
|
-
const valueW =
|
|
126
|
-
|
|
130
|
+
const valueW =
|
|
131
|
+
monoCharWidth > 0
|
|
132
|
+
? valueStr.length * monoCharWidth
|
|
133
|
+
: measureFontTextWidth(font, valueStr);
|
|
134
|
+
const timeW =
|
|
135
|
+
monoCharWidth > 0
|
|
136
|
+
? timeStr.length * monoCharWidth
|
|
137
|
+
: measureFontTextWidth(font, timeStr);
|
|
127
138
|
const contentW = Math.max(valueW, timeW);
|
|
128
139
|
const pillW = contentW + TOOLTIP_PAD_X * 2;
|
|
129
140
|
|
|
@@ -166,6 +177,9 @@ export function computeTooltipLayoutMulti(
|
|
|
166
177
|
padding: ChartPadding,
|
|
167
178
|
canvasWidth: number,
|
|
168
179
|
font: SkFont,
|
|
180
|
+
/** Monospace advance width; when > 0, sizes text by length instead of a
|
|
181
|
+
* per-frame Skia `measureText`. See {@link computeTooltipLayout}. */
|
|
182
|
+
monoCharWidth = 0,
|
|
169
183
|
): TooltipLayout {
|
|
170
184
|
"worklet";
|
|
171
185
|
if (!scrubActive || lines.length === 0) return HIDDEN_TOOLTIP;
|
|
@@ -179,7 +193,10 @@ export function computeTooltipLayoutMulti(
|
|
|
179
193
|
let contentW = 0;
|
|
180
194
|
const lineWidths: number[] = [];
|
|
181
195
|
for (let i = 0; i < n; i++) {
|
|
182
|
-
const w =
|
|
196
|
+
const w =
|
|
197
|
+
monoCharWidth > 0
|
|
198
|
+
? lines[i].text.length * monoCharWidth
|
|
199
|
+
: measureFontTextWidth(font, lines[i].text);
|
|
183
200
|
lineWidths.push(w);
|
|
184
201
|
if (w > contentW) contentW = w;
|
|
185
202
|
}
|
|
@@ -237,6 +254,7 @@ export function computeCandleTooltipLayout(
|
|
|
237
254
|
formatValue: (v: number) => string,
|
|
238
255
|
formatTime: (t: number) => string,
|
|
239
256
|
font: SkFont,
|
|
257
|
+
monoCharWidth = 0,
|
|
240
258
|
): TooltipLayout {
|
|
241
259
|
"worklet";
|
|
242
260
|
if (!scrubActive || !candle) return HIDDEN_TOOLTIP;
|
|
@@ -254,6 +272,7 @@ export function computeCandleTooltipLayout(
|
|
|
254
272
|
padding,
|
|
255
273
|
canvasWidth,
|
|
256
274
|
font,
|
|
275
|
+
monoCharWidth,
|
|
257
276
|
);
|
|
258
277
|
}
|
|
259
278
|
|
|
@@ -279,6 +298,7 @@ export function deriveCrosshairTooltipSingle(
|
|
|
279
298
|
formatValue: (v: number) => string,
|
|
280
299
|
formatTime: (t: number) => string,
|
|
281
300
|
font: SkFont,
|
|
301
|
+
monoCharWidth = 0,
|
|
282
302
|
): TooltipLayout {
|
|
283
303
|
"worklet";
|
|
284
304
|
if (!scrubActive || scrubTime < 0) return HIDDEN_TOOLTIP;
|
|
@@ -292,5 +312,6 @@ export function deriveCrosshairTooltipSingle(
|
|
|
292
312
|
formatValue,
|
|
293
313
|
formatTime,
|
|
294
314
|
font,
|
|
315
|
+
monoCharWidth,
|
|
295
316
|
);
|
|
296
317
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
resolvePadding,
|
|
10
10
|
type ChartPadding,
|
|
11
11
|
} from "../draw/line";
|
|
12
|
-
import type { ChartInsets, LiveChartPalette } from "../types";
|
|
12
|
+
import type { BadgeMetrics, ChartInsets, LiveChartPalette } from "../types";
|
|
13
13
|
|
|
14
14
|
export interface ChartLayoutConfig {
|
|
15
15
|
palette: LiveChartPalette;
|
|
@@ -17,6 +17,8 @@ export interface ChartLayoutConfig {
|
|
|
17
17
|
insetsOverride?: ChartInsets;
|
|
18
18
|
yAxis: boolean;
|
|
19
19
|
badge: boolean;
|
|
20
|
+
/** Badge pill geometry tokens. Omit for built-in defaults. */
|
|
21
|
+
badgeMetrics?: BadgeMetrics;
|
|
20
22
|
/**
|
|
21
23
|
* When true (default if omitted and `badge` is true), reserve the wide right gutter for the badge.
|
|
22
24
|
* Set false when the badge is anchored left of the live dot (`position: "left"`).
|
|
@@ -79,6 +81,7 @@ export function resolveChartLayout(
|
|
|
79
81
|
config.font.getSize(),
|
|
80
82
|
measuredYAxisLabelWidth,
|
|
81
83
|
showTail,
|
|
84
|
+
config.badgeMetrics,
|
|
82
85
|
)
|
|
83
86
|
: config.multiSeriesValueLabel && config.yAxis
|
|
84
87
|
? Math.max(
|
|
@@ -91,7 +94,12 @@ export function resolveChartLayout(
|
|
|
91
94
|
? Math.max(measuredYAxisLabelWidth + 16 + dotR * 2, 44)
|
|
92
95
|
: resolveAutoRight(false, false);
|
|
93
96
|
} else {
|
|
94
|
-
rightPad = resolveAutoRight(
|
|
97
|
+
rightPad = resolveAutoRight(
|
|
98
|
+
config.yAxis,
|
|
99
|
+
badgeUsesRightGutter,
|
|
100
|
+
showTail,
|
|
101
|
+
config.badgeMetrics,
|
|
102
|
+
);
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
if (config.pulse && config.yAxis && config.insetsOverride?.right == null) {
|
|
@@ -121,6 +129,7 @@ export function resolveChartLayout(
|
|
|
121
129
|
false,
|
|
122
130
|
xAxis,
|
|
123
131
|
showTail,
|
|
132
|
+
config.badgeMetrics,
|
|
124
133
|
);
|
|
125
134
|
|
|
126
135
|
let padding: ChartPadding = { ...base, right: rightPad, left: leftPad };
|