react-native-livechart 3.12.0 → 4.1.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/dist/components/BadgeOverlay.d.ts +9 -1
- package/dist/components/BadgeOverlay.d.ts.map +1 -1
- package/dist/components/ChartOverlayLayer.d.ts +19 -0
- package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
- package/dist/components/CustomMarkerOverlay.d.ts +16 -6
- package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
- package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
- package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
- package/dist/components/CustomTooltipOverlay.d.ts +5 -2
- package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
- package/dist/components/DotOverlay.d.ts +7 -1
- 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/MarkerOverlay.d.ts +6 -3
- package/dist/components/MarkerOverlay.d.ts.map +1 -1
- package/dist/components/MultiSeriesDots.d.ts +4 -1
- package/dist/components/MultiSeriesDots.d.ts.map +1 -1
- package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
- package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
- package/dist/components/ReferenceLineOverlay.d.ts +25 -2
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/XAxisOverlay.d.ts +7 -1
- package/dist/components/XAxisOverlay.d.ts.map +1 -1
- package/dist/components/YAxisOverlay.d.ts +12 -1
- package/dist/components/YAxisOverlay.d.ts.map +1 -1
- package/dist/constants.d.ts +13 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +29 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +62 -2
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +58 -7
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/grid.d.ts +13 -1
- package/dist/draw/grid.d.ts.map +1 -1
- package/dist/draw/markerAtlas.d.ts +20 -2
- package/dist/draw/markerAtlas.d.ts.map +1 -1
- package/dist/draw/volume.d.ts +25 -0
- package/dist/draw/volume.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +12 -6
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/overlayScale.d.ts +22 -0
- package/dist/hooks/overlayScale.d.ts.map +1 -0
- package/dist/hooks/resolveChartLayout.d.ts +9 -0
- package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
- package/dist/hooks/useBadge.d.ts +22 -1
- package/dist/hooks/useBadge.d.ts.map +1 -1
- package/dist/hooks/useCandlePaths.d.ts +6 -0
- package/dist/hooks/useCandlePaths.d.ts.map +1 -1
- package/dist/hooks/useChartOverlayContext.d.ts +45 -0
- package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
- package/dist/hooks/useChartPaths.d.ts +8 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +9 -2
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useLiveDot.d.ts +6 -1
- package/dist/hooks/useLiveDot.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts +9 -4
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +82 -0
- package/dist/hooks/usePanScroll.d.ts.map +1 -0
- package/dist/hooks/usePinchZoom.d.ts +74 -0
- package/dist/hooks/usePinchZoom.d.ts.map +1 -0
- package/dist/hooks/useReferenceDrag.d.ts +24 -0
- package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
- package/dist/hooks/useReferenceLine.d.ts +23 -4
- package/dist/hooks/useReferenceLine.d.ts.map +1 -1
- package/dist/hooks/useReferenceLinePress.d.ts +5 -1
- package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
- package/dist/hooks/useVisibleRange.d.ts +39 -0
- package/dist/hooks/useVisibleRange.d.ts.map +1 -0
- package/dist/hooks/useYAxis.d.ts +1 -1
- package/dist/hooks/useYAxis.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/markerCluster.d.ts +47 -0
- package/dist/math/markerCluster.d.ts.map +1 -0
- package/dist/math/markers.d.ts +10 -0
- package/dist/math/markers.d.ts.map +1 -1
- package/dist/math/referenceDrag.d.ts +24 -0
- package/dist/math/referenceDrag.d.ts.map +1 -0
- package/dist/math/referenceGroup.d.ts +31 -0
- package/dist/math/referenceGroup.d.ts.map +1 -0
- package/dist/math/referenceLines.d.ts +33 -7
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/types.d.ts +524 -21
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/BadgeOverlay.tsx +26 -1
- package/src/components/ChartOverlayLayer.tsx +32 -0
- package/src/components/CustomMarkerOverlay.tsx +150 -57
- package/src/components/CustomReferenceLineOverlay.tsx +250 -0
- package/src/components/CustomTooltipOverlay.tsx +27 -7
- package/src/components/DotOverlay.tsx +9 -0
- package/src/components/LiveChart.tsx +550 -32
- package/src/components/LiveChartSeries.tsx +111 -5
- package/src/components/MarkerOverlay.tsx +92 -6
- package/src/components/MultiSeriesDots.tsx +12 -3
- package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
- package/src/components/ReferenceLineOverlay.tsx +105 -32
- package/src/components/XAxisOverlay.tsx +9 -2
- package/src/components/YAxisOverlay.tsx +49 -28
- package/src/constants.ts +17 -0
- package/src/core/liveChartEngineTick.ts +81 -8
- package/src/core/liveChartSeriesEngineTick.ts +50 -9
- package/src/core/resolveConfig.ts +133 -2
- package/src/core/useLiveChartEngine.ts +111 -11
- package/src/core/useLiveChartSeriesEngine.ts +26 -3
- package/src/draw/grid.ts +71 -2
- package/src/draw/markerAtlas.ts +117 -3
- package/src/draw/volume.ts +190 -0
- package/src/hooks/crosshairShared.ts +37 -9
- package/src/hooks/overlayScale.ts +59 -0
- package/src/hooks/resolveChartLayout.ts +33 -0
- package/src/hooks/useBadge.ts +89 -21
- package/src/hooks/useCandlePaths.ts +104 -7
- package/src/hooks/useChartOverlayContext.ts +93 -0
- package/src/hooks/useChartPaths.ts +14 -1
- package/src/hooks/useCrosshair.ts +33 -2
- package/src/hooks/useLiveDot.ts +16 -5
- package/src/hooks/useMarkers.ts +39 -11
- package/src/hooks/usePanScroll.ts +253 -0
- package/src/hooks/usePinchZoom.ts +189 -0
- package/src/hooks/useReferenceDrag.ts +254 -0
- package/src/hooks/useReferenceLine.ts +56 -9
- package/src/hooks/useReferenceLinePress.ts +16 -2
- package/src/hooks/useVisibleRange.ts +119 -0
- package/src/hooks/useYAxis.ts +2 -0
- package/src/index.ts +17 -1
- package/src/math/markerCluster.ts +214 -0
- package/src/math/markers.ts +35 -3
- package/src/math/referenceDrag.ts +66 -0
- package/src/math/referenceGroup.ts +65 -0
- package/src/math/referenceLines.ts +76 -19
- package/src/types.ts +544 -21
package/src/hooks/useBadge.ts
CHANGED
|
@@ -39,6 +39,27 @@ export function useBadge(
|
|
|
39
39
|
background?: string,
|
|
40
40
|
badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS,
|
|
41
41
|
badgeColorSpeed: number = MOTION_METRICS_DEFAULTS.badgeColorSpeed,
|
|
42
|
+
/**
|
|
43
|
+
* Floating-axis mode: render a tail-less pill right-aligned at the canvas edge
|
|
44
|
+
* (over a full-width plot) instead of inside a reserved right gutter. See
|
|
45
|
+
* {@link YAxisConfig.float}.
|
|
46
|
+
*/
|
|
47
|
+
float = false,
|
|
48
|
+
/**
|
|
49
|
+
* Smoothed value at the visible window's right edge (engine `edgeValue`). When
|
|
50
|
+
* {@link followViewEdge} is set, the badge tracks this instead of the live value.
|
|
51
|
+
*/
|
|
52
|
+
edgeValue?: SharedValue<number>,
|
|
53
|
+
/** Track the visible window's right-edge price while scrolled back. */
|
|
54
|
+
followViewEdge = false,
|
|
55
|
+
/**
|
|
56
|
+
* Pill corner radius in pixels. `undefined` → capsule (`pillHeight / 2`).
|
|
57
|
+
* Clamped to `[0, pillHeight / 2]`. The pointed tail (right-gutter mode) keeps
|
|
58
|
+
* anchoring on the vertical center regardless of this value.
|
|
59
|
+
*/
|
|
60
|
+
radius?: number,
|
|
61
|
+
/** Label text color override; falls back to the variant/theme rule. */
|
|
62
|
+
textColorOverride?: string,
|
|
42
63
|
) {
|
|
43
64
|
const colorR = useSharedValue(0);
|
|
44
65
|
const colorG = useSharedValue(0);
|
|
@@ -71,21 +92,41 @@ export function useBadge(
|
|
|
71
92
|
const dMin = engine.displayMin.get();
|
|
72
93
|
const dMax = engine.displayMax.get();
|
|
73
94
|
const valRange = dMax - dMin;
|
|
95
|
+
// Follow the visible window's right-edge price while scrolled back, else the
|
|
96
|
+
// live value (badge.followViewEdge). engine `edgeValue` already collapses to
|
|
97
|
+
// the live value when not scrolled, so the live badge is unchanged.
|
|
98
|
+
const liveVal =
|
|
99
|
+
followViewEdge && edgeValue ? edgeValue.get() : engine.displayValue.get();
|
|
74
100
|
const dotY =
|
|
75
101
|
valRange === 0
|
|
76
102
|
? padding.top + chartH / 2
|
|
77
|
-
: padding.top +
|
|
78
|
-
((dMax - engine.displayValue.get()) / valRange) * chartH;
|
|
103
|
+
: padding.top + ((dMax - liveVal) / valRange) * chartH;
|
|
79
104
|
|
|
80
|
-
const text = formatValue(
|
|
105
|
+
const text = formatValue(liveVal);
|
|
81
106
|
const textW = measureFontTextWidth(font, text);
|
|
82
107
|
|
|
83
108
|
const pillH = font.getSize() + badgeMetrics.padY * 2;
|
|
84
|
-
|
|
109
|
+
// `midY` is the pill's vertical center (the tail anchors here); `capR` is the
|
|
110
|
+
// corner radius — the capsule (midY) by default, or a clamped custom radius.
|
|
111
|
+
const midY = pillH / 2;
|
|
112
|
+
const capR = radius == null ? midY : Math.max(0, Math.min(radius, midY));
|
|
85
113
|
const badgeY = dotY - pillH / 2;
|
|
86
114
|
let textX: number;
|
|
87
115
|
|
|
88
|
-
if (
|
|
116
|
+
if (float) {
|
|
117
|
+
// Floating price tag: a tail-less pill right-aligned at the canvas edge,
|
|
118
|
+
// floating over the full-width plot (no reserved gutter). Its pill bg keeps
|
|
119
|
+
// the live value readable over the candles.
|
|
120
|
+
const pillW = 2 * badgeMetrics.padX + textW;
|
|
121
|
+
const bodyRight = w - badgeMetrics.marginEdge;
|
|
122
|
+
const bodyLeft = bodyRight - pillW;
|
|
123
|
+
textX = (bodyLeft + bodyRight - textW) / 2;
|
|
124
|
+
b.addRRect({
|
|
125
|
+
rect: { x: bodyLeft, y: badgeY, width: pillW, height: pillH },
|
|
126
|
+
rx: capR,
|
|
127
|
+
ry: capR,
|
|
128
|
+
});
|
|
129
|
+
} else if (position === "left") {
|
|
89
130
|
// Pill to the left of the live dot; no tail (`showTail` applies to right gutter only).
|
|
90
131
|
const dotXPos = w - padding.right;
|
|
91
132
|
const pillW = 2 * badgeMetrics.padX + textW;
|
|
@@ -95,8 +136,8 @@ export function useBadge(
|
|
|
95
136
|
textX = (bodyLeft + bodyRight - textW) / 2;
|
|
96
137
|
b.addRRect({
|
|
97
138
|
rect: { x: bodyLeft, y: badgeY, width: pillBodyW, height: pillH },
|
|
98
|
-
rx:
|
|
99
|
-
ry:
|
|
139
|
+
rx: capR,
|
|
140
|
+
ry: capR,
|
|
100
141
|
});
|
|
101
142
|
} else {
|
|
102
143
|
// Right-gutter badge (default): asymmetric layout with optional tail.
|
|
@@ -115,28 +156,54 @@ export function useBadge(
|
|
|
115
156
|
|
|
116
157
|
if (showTail) {
|
|
117
158
|
const badgeX = w - padding.right + badgeMetrics.dotGap;
|
|
118
|
-
const
|
|
159
|
+
const bodyRightX = badgeX + tl + pillW;
|
|
119
160
|
|
|
120
161
|
b.moveTo(badgeX + tl, badgeY);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
if (capR >= midY) {
|
|
163
|
+
// Capsule cap: a single right-hand semicircle (the default badge).
|
|
164
|
+
const cx = tl + pillW - capR;
|
|
165
|
+
b.lineTo(badgeX + cx, badgeY);
|
|
166
|
+
b.arcToOval(
|
|
167
|
+
{ x: badgeX + cx - capR, y: badgeY, width: capR * 2, height: pillH },
|
|
168
|
+
-90,
|
|
169
|
+
180,
|
|
170
|
+
false,
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
// Custom (smaller) corner radius: square body with rounded right corners.
|
|
174
|
+
b.lineTo(bodyRightX - capR, badgeY);
|
|
175
|
+
b.arcToOval(
|
|
176
|
+
{ x: bodyRightX - capR * 2, y: badgeY, width: capR * 2, height: capR * 2 },
|
|
177
|
+
-90,
|
|
178
|
+
90,
|
|
179
|
+
false,
|
|
180
|
+
);
|
|
181
|
+
b.lineTo(bodyRightX, badgeY + pillH - capR);
|
|
182
|
+
b.arcToOval(
|
|
183
|
+
{
|
|
184
|
+
x: bodyRightX - capR * 2,
|
|
185
|
+
y: badgeY + pillH - capR * 2,
|
|
186
|
+
width: capR * 2,
|
|
187
|
+
height: capR * 2,
|
|
188
|
+
},
|
|
189
|
+
0,
|
|
190
|
+
90,
|
|
191
|
+
false,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
128
194
|
b.lineTo(badgeX + tl, badgeY + pillH);
|
|
195
|
+
// Tail tip stays on the vertical center (`midY`) regardless of corner radius.
|
|
129
196
|
b.cubicTo(
|
|
130
197
|
badgeX + badgeMetrics.tailLength + 2,
|
|
131
198
|
badgeY + pillH,
|
|
132
199
|
badgeX + 3,
|
|
133
|
-
badgeY +
|
|
200
|
+
badgeY + midY + badgeMetrics.tailSpread,
|
|
134
201
|
badgeX,
|
|
135
|
-
badgeY +
|
|
202
|
+
badgeY + midY,
|
|
136
203
|
);
|
|
137
204
|
b.cubicTo(
|
|
138
205
|
badgeX + 3,
|
|
139
|
-
badgeY +
|
|
206
|
+
badgeY + midY - badgeMetrics.tailSpread,
|
|
140
207
|
badgeX + badgeMetrics.tailLength + 2,
|
|
141
208
|
badgeY,
|
|
142
209
|
badgeX + tl,
|
|
@@ -146,8 +213,8 @@ export function useBadge(
|
|
|
146
213
|
} else {
|
|
147
214
|
b.addRRect({
|
|
148
215
|
rect: { x: bodyLeft, y: badgeY, width: pillW, height: pillH },
|
|
149
|
-
rx:
|
|
150
|
-
ry:
|
|
216
|
+
rx: capR,
|
|
217
|
+
ry: capR,
|
|
151
218
|
});
|
|
152
219
|
}
|
|
153
220
|
}
|
|
@@ -182,7 +249,8 @@ export function useBadge(
|
|
|
182
249
|
}
|
|
183
250
|
|
|
184
251
|
const textColor =
|
|
185
|
-
|
|
252
|
+
textColorOverride ??
|
|
253
|
+
(variant === "minimal" ? "rgba(100,100,100,1)" : palette.badgeText);
|
|
186
254
|
|
|
187
255
|
return { path: b.detach(), textX, textY, text, bgColor, textColor };
|
|
188
256
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { CANDLE_METRICS_DEFAULTS, MS_PER_FRAME_60FPS } from "../constants";
|
|
9
9
|
import type { SingleEngineState } from "../core/useLiveChartEngine";
|
|
10
10
|
import { buildCandleGeometry } from "../draw/candle";
|
|
11
|
+
import { buildVolumeGeometry } from "../draw/volume";
|
|
11
12
|
import type { ChartPadding } from "../draw/line";
|
|
12
13
|
import { lerp } from "../math/lerp";
|
|
13
14
|
import type { CandleMetrics, CandlePoint } from "../types";
|
|
@@ -29,6 +30,10 @@ export function useCandlePaths(
|
|
|
29
30
|
candleWidthSecs: number,
|
|
30
31
|
active: boolean,
|
|
31
32
|
candleMetrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
|
|
33
|
+
/** Reserved volume-band height (px). `0` = no volume bars. */
|
|
34
|
+
volumeBandHeight = 0,
|
|
35
|
+
/** Corner radius (px) of the volume bars. */
|
|
36
|
+
volumeRadius = 0,
|
|
32
37
|
/** Static charts run no loops: register without starting. Default `true`. */
|
|
33
38
|
autostart = true,
|
|
34
39
|
) {
|
|
@@ -39,6 +44,8 @@ export function useCandlePaths(
|
|
|
39
44
|
const downBodiesBuilder = usePathBuilder();
|
|
40
45
|
const upWicksBuilder = usePathBuilder();
|
|
41
46
|
const downWicksBuilder = usePathBuilder();
|
|
47
|
+
const upBarsBuilder = usePathBuilder();
|
|
48
|
+
const downBarsBuilder = usePathBuilder();
|
|
42
49
|
|
|
43
50
|
useFrameCallback((frameInfo) => {
|
|
44
51
|
"worklet";
|
|
@@ -76,11 +83,20 @@ export function useCandlePaths(
|
|
|
76
83
|
const upBodiesPath = useDerivedValue(() => {
|
|
77
84
|
const b = upBodiesBuilder.value;
|
|
78
85
|
const { bodies } = geometry.value;
|
|
86
|
+
const radius = candleMetrics.bodyRadius;
|
|
79
87
|
for (let i = 0; i < bodies.length; i++) {
|
|
80
88
|
if (bodies[i].up) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
89
|
+
const bd = bodies[i];
|
|
90
|
+
const rr = radius > 0 ? Math.min(radius, bd.w / 2, bd.h / 2) : 0;
|
|
91
|
+
if (rr > 0) {
|
|
92
|
+
b.addRRect({
|
|
93
|
+
rect: { x: bd.x, y: bd.y, width: bd.w, height: bd.h },
|
|
94
|
+
rx: rr,
|
|
95
|
+
ry: rr,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
b.addRect(Skia.XYWHRect(bd.x, bd.y, bd.w, bd.h));
|
|
99
|
+
}
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
return b.detach();
|
|
@@ -90,11 +106,20 @@ export function useCandlePaths(
|
|
|
90
106
|
const downBodiesPath = useDerivedValue(() => {
|
|
91
107
|
const b = downBodiesBuilder.value;
|
|
92
108
|
const { bodies } = geometry.value;
|
|
109
|
+
const radius = candleMetrics.bodyRadius;
|
|
93
110
|
for (let i = 0; i < bodies.length; i++) {
|
|
94
111
|
if (!bodies[i].up) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
112
|
+
const bd = bodies[i];
|
|
113
|
+
const rr = radius > 0 ? Math.min(radius, bd.w / 2, bd.h / 2) : 0;
|
|
114
|
+
if (rr > 0) {
|
|
115
|
+
b.addRRect({
|
|
116
|
+
rect: { x: bd.x, y: bd.y, width: bd.w, height: bd.h },
|
|
117
|
+
rx: rr,
|
|
118
|
+
ry: rr,
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
b.addRect(Skia.XYWHRect(bd.x, bd.y, bd.w, bd.h));
|
|
122
|
+
}
|
|
98
123
|
}
|
|
99
124
|
}
|
|
100
125
|
return b.detach();
|
|
@@ -126,5 +151,77 @@ export function useCandlePaths(
|
|
|
126
151
|
return b.detach();
|
|
127
152
|
});
|
|
128
153
|
|
|
129
|
-
|
|
154
|
+
// Volume bars share the candle window + (lerped) candle width so each bar sits
|
|
155
|
+
// directly under its candle body. Empty unless a volume band is reserved.
|
|
156
|
+
/* istanbul ignore next -- worklet */
|
|
157
|
+
const volumeGeometry = useDerivedValue(() => {
|
|
158
|
+
if (!active || !candles || volumeBandHeight <= 0) return { bars: [] };
|
|
159
|
+
return buildVolumeGeometry(
|
|
160
|
+
candles.value,
|
|
161
|
+
liveCandle?.value ?? null,
|
|
162
|
+
padding,
|
|
163
|
+
engine.canvasWidth.value,
|
|
164
|
+
engine.canvasHeight.value,
|
|
165
|
+
engine.timestamp.value - engine.displayWindow.value,
|
|
166
|
+
engine.displayWindow.value,
|
|
167
|
+
volumeBandHeight,
|
|
168
|
+
displayCandleWidth.get(),
|
|
169
|
+
candleMetrics,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/* istanbul ignore next -- worklet */
|
|
174
|
+
const upBarsPath = useDerivedValue(() => {
|
|
175
|
+
const b = upBarsBuilder.value;
|
|
176
|
+
const { bars } = volumeGeometry.value;
|
|
177
|
+
for (let i = 0; i < bars.length; i++) {
|
|
178
|
+
if (bars[i].up) {
|
|
179
|
+
const bar = bars[i];
|
|
180
|
+
const rr =
|
|
181
|
+
volumeRadius > 0 ? Math.min(volumeRadius, bar.w / 2, bar.h / 2) : 0;
|
|
182
|
+
if (rr > 0) {
|
|
183
|
+
b.addRRect({
|
|
184
|
+
rect: { x: bar.x, y: bar.y, width: bar.w, height: bar.h },
|
|
185
|
+
rx: rr,
|
|
186
|
+
ry: rr,
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
b.addRect(Skia.XYWHRect(bar.x, bar.y, bar.w, bar.h));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return b.detach();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/* istanbul ignore next -- worklet */
|
|
197
|
+
const downBarsPath = useDerivedValue(() => {
|
|
198
|
+
const b = downBarsBuilder.value;
|
|
199
|
+
const { bars } = volumeGeometry.value;
|
|
200
|
+
for (let i = 0; i < bars.length; i++) {
|
|
201
|
+
if (!bars[i].up) {
|
|
202
|
+
const bar = bars[i];
|
|
203
|
+
const rr =
|
|
204
|
+
volumeRadius > 0 ? Math.min(volumeRadius, bar.w / 2, bar.h / 2) : 0;
|
|
205
|
+
if (rr > 0) {
|
|
206
|
+
b.addRRect({
|
|
207
|
+
rect: { x: bar.x, y: bar.y, width: bar.w, height: bar.h },
|
|
208
|
+
rx: rr,
|
|
209
|
+
ry: rr,
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
b.addRect(Skia.XYWHRect(bar.x, bar.y, bar.w, bar.h));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return b.detach();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
upBodiesPath,
|
|
221
|
+
downBodiesPath,
|
|
222
|
+
upWicksPath,
|
|
223
|
+
downWicksPath,
|
|
224
|
+
upBarsPath,
|
|
225
|
+
downBarsPath,
|
|
226
|
+
} as const;
|
|
130
227
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
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 type { ChartOverlayContext, ChartScale } from "../types";
|
|
7
|
+
import { priceToY, timeToX, xToTime, yToPrice } from "./overlayScale";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds the {@link ChartOverlayContext} handed to a custom
|
|
11
|
+
* {@link LiveChartProps.renderOverlay}: a single per-frame {@link ChartScale}
|
|
12
|
+
* SharedValue plus the pure price↔pixel / time↔pixel mapping worklets.
|
|
13
|
+
*
|
|
14
|
+
* The `scale` derived value reads the engine SharedValues (range, window, "now")
|
|
15
|
+
* and the padding each frame, so a consumer's `useAnimatedStyle` becomes reactive
|
|
16
|
+
* just by reading `scale.get()` — that read subscribes it to per-frame updates
|
|
17
|
+
* (Reanimated only observes SharedValues found directly in a worklet's closure,
|
|
18
|
+
* not those hidden behind a function call). The mappings stay pure, taking the
|
|
19
|
+
* snapshot as an argument.
|
|
20
|
+
*/
|
|
21
|
+
export function useChartOverlayContext(
|
|
22
|
+
engine: ChartEngineLayout,
|
|
23
|
+
padding: ChartPadding,
|
|
24
|
+
): ChartOverlayContext {
|
|
25
|
+
const { top: padTop, bottom: padBottom, left: padLeft, right: padRight } = padding;
|
|
26
|
+
|
|
27
|
+
const scale = useDerivedValue<ChartScale>(() => {
|
|
28
|
+
const width = engine.canvasWidth.get();
|
|
29
|
+
const height = engine.canvasHeight.get();
|
|
30
|
+
return {
|
|
31
|
+
min: engine.displayMin.get(),
|
|
32
|
+
max: engine.displayMax.get(),
|
|
33
|
+
window: engine.displayWindow.get(),
|
|
34
|
+
now: engine.timestamp.get(),
|
|
35
|
+
plot: {
|
|
36
|
+
left: padLeft,
|
|
37
|
+
top: padTop,
|
|
38
|
+
right: width - padRight,
|
|
39
|
+
bottom: height - padBottom,
|
|
40
|
+
width,
|
|
41
|
+
height,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}, [engine, padLeft, padTop, padRight, padBottom]);
|
|
45
|
+
|
|
46
|
+
return useMemo<ChartOverlayContext>(
|
|
47
|
+
() => ({ scale, priceToY, yToPrice, timeToX, xToTime }),
|
|
48
|
+
[scale],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reactive Y for a price: projects `price` to its canvas Y pixel and returns it as
|
|
54
|
+
* a `SharedValue<number>` that tracks the live axis on the UI thread. The
|
|
55
|
+
* **recommended** way to place something at a price in a {@link LiveChartProps.renderOverlay}
|
|
56
|
+
* — read the returned value in your `useAnimatedStyle` like any SharedValue; the
|
|
57
|
+
* subscription to the chart's scale is handled here, so it can't be forgotten.
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* function PriceLevel({ ctx, price }) {
|
|
61
|
+
* const y = usePriceY(ctx, price);
|
|
62
|
+
* const style = useAnimatedStyle(() => ({ transform: [{ translateY: y.get() }] }));
|
|
63
|
+
* return <Animated.View style={style} />;
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* It's a hook, so call it once per level (render one component per price). For
|
|
68
|
+
* one-off math off the render path (e.g. a gesture handler), use the pure
|
|
69
|
+
* {@link ChartOverlayContext.priceToY} with `ctx.scale.get()` instead.
|
|
70
|
+
*/
|
|
71
|
+
export function usePriceY(
|
|
72
|
+
ctx: ChartOverlayContext,
|
|
73
|
+
price: number,
|
|
74
|
+
): SharedValue<number> {
|
|
75
|
+
const { scale, priceToY: toY } = ctx;
|
|
76
|
+
// Destructure `scale` so it sits directly in the worklet's closure (Reanimated
|
|
77
|
+
// observes top-level closure SharedValues, not ones reached via `ctx.scale`).
|
|
78
|
+
return useDerivedValue(() => toY(price, scale.get()), [scale, toY, price]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reactive X for a timestamp — the time↔pixel sibling of {@link usePriceY}.
|
|
83
|
+
* Projects `time` (unix seconds) to its canvas X pixel as a `SharedValue<number>`
|
|
84
|
+
* that tracks the scrolling window on the UI thread. Read it in your
|
|
85
|
+
* `useAnimatedStyle`; call once per element.
|
|
86
|
+
*/
|
|
87
|
+
export function useTimeX(
|
|
88
|
+
ctx: ChartOverlayContext,
|
|
89
|
+
time: number,
|
|
90
|
+
): SharedValue<number> {
|
|
91
|
+
const { scale, timeToX: toX } = ctx;
|
|
92
|
+
return useDerivedValue(() => toX(time, scale.get()), [scale, toX, time]);
|
|
93
|
+
}
|
|
@@ -27,6 +27,14 @@ export function useChartPaths(
|
|
|
27
27
|
thresholdY?: SharedValue<number>,
|
|
28
28
|
/** Draw the line/fill as a straight polyline instead of the monotone cubic. */
|
|
29
29
|
linear = false,
|
|
30
|
+
/**
|
|
31
|
+
* Value at the visible window's right edge (engine `edgeValue`). With
|
|
32
|
+
* `followViewEdge`, the line's right-edge tip uses this instead of the live
|
|
33
|
+
* `displayValue` — so while time-scrolled the line ends at the last visible
|
|
34
|
+
* price rather than dropping to the (off-screen) live value.
|
|
35
|
+
*/
|
|
36
|
+
edgeValue?: SharedValue<number>,
|
|
37
|
+
followViewEdge = false,
|
|
30
38
|
) {
|
|
31
39
|
const lineBuilder = usePathBuilder();
|
|
32
40
|
const fillBuilder = usePathBuilder();
|
|
@@ -53,9 +61,14 @@ export function useChartPaths(
|
|
|
53
61
|
const cache = cacheRef.current!;
|
|
54
62
|
cache.ptsTick = !cache.ptsTick;
|
|
55
63
|
const buf = cache.ptsTick ? cache.ptsA : cache.ptsB;
|
|
64
|
+
// While scrolled back with `followViewEdge`, tip the line at the view-edge
|
|
65
|
+
// price (engine `edgeValue`) instead of the live value — otherwise the line
|
|
66
|
+
// drops from the last visible point to the off-screen live value at the edge.
|
|
67
|
+
const tipValue =
|
|
68
|
+
followViewEdge && edgeValue ? edgeValue.get() : engine.displayValue.get();
|
|
56
69
|
const realPts = buildLinePoints(
|
|
57
70
|
engine.data.get(),
|
|
58
|
-
|
|
71
|
+
tipValue,
|
|
59
72
|
engine.timestamp.get(),
|
|
60
73
|
engine.displayWindow.get(),
|
|
61
74
|
engine.displayMin.get(),
|
|
@@ -130,13 +130,20 @@ export function useCrosshair(
|
|
|
130
130
|
*/
|
|
131
131
|
deferTapHit?: (x: number, y: number) => boolean,
|
|
132
132
|
/** Where the tooltip pill sits relative to the scrub line. Default `"side"`. */
|
|
133
|
-
tooltipPlacement: "side" | "top" | "bottom" = "side",
|
|
133
|
+
tooltipPlacement: "side" | "top" | "bottom" | "point" = "side",
|
|
134
134
|
/** Render the value row in the default tooltip. Default `true`. */
|
|
135
135
|
tooltipShowValue = true,
|
|
136
136
|
/** Render the time row in the default tooltip. Default `true`. */
|
|
137
137
|
tooltipShowTime = true,
|
|
138
138
|
/** Gap (px) between the tooltip and the plot edge it's pinned to. Default `8`. */
|
|
139
139
|
tooltipMargin = 8,
|
|
140
|
+
/**
|
|
141
|
+
* Height (px) of a bottom band to exclude from scrub recognition — the
|
|
142
|
+
* axis-drag "grab the time ruler" strip, so a drag there scrolls instead of
|
|
143
|
+
* scrubbing (and a vertical drag falls through to the parent). `0` = no
|
|
144
|
+
* exclusion (the default; scrub covers the whole plot).
|
|
145
|
+
*/
|
|
146
|
+
scrubBottomExclude = 0,
|
|
140
147
|
): CrosshairState {
|
|
141
148
|
const scrubX = useSharedValue(-1);
|
|
142
149
|
const scrubActive = useSharedValue(false);
|
|
@@ -159,6 +166,7 @@ export function useCrosshair(
|
|
|
159
166
|
const hasScrubAction = scrubAction != null;
|
|
160
167
|
const hasOnScrubAction = onScrubAction != null;
|
|
161
168
|
const dismissOnTapOutside = scrubAction?.dismissOnTapOutside ?? false;
|
|
169
|
+
const dismissOnAction = scrubAction?.dismissOnAction ?? false;
|
|
162
170
|
const snapIncrement = scrubAction?.snap;
|
|
163
171
|
const actionIcon = scrubAction?.icon ?? "+";
|
|
164
172
|
const actionShowText = scrubAction?.text ?? true;
|
|
@@ -264,6 +272,7 @@ export function useCrosshair(
|
|
|
264
272
|
tooltipShowTime,
|
|
265
273
|
engine.canvasHeight.get(),
|
|
266
274
|
tooltipMargin,
|
|
275
|
+
scrubDotY.get(),
|
|
267
276
|
);
|
|
268
277
|
});
|
|
269
278
|
|
|
@@ -506,6 +515,12 @@ export function useCrosshair(
|
|
|
506
515
|
}
|
|
507
516
|
return;
|
|
508
517
|
}
|
|
518
|
+
// Defer to a marker / pressable badge under the finger: don't start a
|
|
519
|
+
// live scrub (or drop a crosshair) where the press is routed to an
|
|
520
|
+
// overlay tap. `scrubActive` is only set here, so bailing in `onStart`
|
|
521
|
+
// also keeps a follow-on drag from showing a crosshair — no `onUpdate`
|
|
522
|
+
// guard needed. (Plain-scrub counterpart of the scrub-action tap defer.)
|
|
523
|
+
if (deferTapHit !== undefined && deferTapHit(e.x, e.y)) return;
|
|
509
524
|
scrubX.set(e.x);
|
|
510
525
|
scrubActive.set(true);
|
|
511
526
|
gestureStarted.set(true);
|
|
@@ -562,6 +577,14 @@ export function useCrosshair(
|
|
|
562
577
|
gesture = gesture.activeOffsetX([-25, 25]).failOffsetY([-25, 25]);
|
|
563
578
|
}
|
|
564
579
|
|
|
580
|
+
// Axis-drag time-scroll: carve the bottom "time ruler" band out of the scrub's
|
|
581
|
+
// hit area so a drag starting there scrolls (the pan-scroll gesture owns it)
|
|
582
|
+
// and never trips the crosshair. `shouldCancelWhenOutside(false)` above keeps a
|
|
583
|
+
// scrub that *started* in the plot tracking on into the band.
|
|
584
|
+
if (scrubBottomExclude > 0) {
|
|
585
|
+
gesture = gesture.hitSlop({ bottom: -scrubBottomExclude });
|
|
586
|
+
}
|
|
587
|
+
|
|
565
588
|
// Tap: place/move the reticle, press the action badge, or dismiss the lock.
|
|
566
589
|
// Composed ahead of the pan by the controller, so a tap is never swallowed.
|
|
567
590
|
// Built only in scrub-action mode (the plain-scrub path never constructs a Tap).
|
|
@@ -589,6 +612,9 @@ export function useCrosshair(
|
|
|
589
612
|
lockY.get(),
|
|
590
613
|
candleJson,
|
|
591
614
|
);
|
|
615
|
+
// Clear the reticle once the action fires, so no crosshair lingers after
|
|
616
|
+
// the order is placed via the badge.
|
|
617
|
+
if (dismissOnAction) lockActive.set(false);
|
|
592
618
|
}
|
|
593
619
|
return;
|
|
594
620
|
}
|
|
@@ -626,12 +652,17 @@ export function useCrosshair(
|
|
|
626
652
|
lockActive.set(true);
|
|
627
653
|
};
|
|
628
654
|
|
|
629
|
-
|
|
655
|
+
let tapGesture = hasScrubAction
|
|
630
656
|
? Gesture.Tap()
|
|
631
657
|
.maxDuration(250)
|
|
632
658
|
.maxDistance(SCRUB_ACTION_TAP_SLOP)
|
|
633
659
|
.onEnd(handleActionTap)
|
|
634
660
|
: undefined;
|
|
661
|
+
// Keep the axis-drag band scroll-only for taps too — a tap there shouldn't
|
|
662
|
+
// drop an order-ticket reticle.
|
|
663
|
+
if (tapGesture && scrubBottomExclude > 0) {
|
|
664
|
+
tapGesture = tapGesture.hitSlop({ bottom: -scrubBottomExclude });
|
|
665
|
+
}
|
|
635
666
|
|
|
636
667
|
return {
|
|
637
668
|
scrubX,
|
package/src/hooks/useLiveDot.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useDerivedValue } from "react-native-reanimated";
|
|
1
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
2
2
|
import type { SingleEngineState } from "../core/useLiveChartEngine";
|
|
3
3
|
import type { ChartPadding } from "../draw/line";
|
|
4
4
|
|
|
@@ -6,8 +6,17 @@ import type { ChartPadding } from "../draw/line";
|
|
|
6
6
|
* Derive the live dot position (right edge of the chart, mapped to current value).
|
|
7
7
|
* Returns `{ dotX, dotY }` as shared values. Coordinates are set to `-100`
|
|
8
8
|
* (off-screen sentinel) when canvas dimensions are unavailable.
|
|
9
|
+
*
|
|
10
|
+
* With `followViewEdge` + `edgeValue`, the dot (and the value line that shares
|
|
11
|
+
* `dotY`) tracks the visible window's right-edge price while scrolled back, so it
|
|
12
|
+
* stays aligned with a `followViewEdge` badge instead of marking the live value.
|
|
9
13
|
*/
|
|
10
|
-
export function useLiveDot(
|
|
14
|
+
export function useLiveDot(
|
|
15
|
+
engine: SingleEngineState,
|
|
16
|
+
padding: ChartPadding,
|
|
17
|
+
edgeValue?: SharedValue<number>,
|
|
18
|
+
followViewEdge = false,
|
|
19
|
+
) {
|
|
11
20
|
const dotX = useDerivedValue(() => {
|
|
12
21
|
const w = engine.canvasWidth.value;
|
|
13
22
|
if (w === 0) return -100;
|
|
@@ -22,9 +31,11 @@ export function useLiveDot(engine: SingleEngineState, padding: ChartPadding) {
|
|
|
22
31
|
const dMax = engine.displayMax.value;
|
|
23
32
|
const valRange = dMax - dMin;
|
|
24
33
|
if (valRange === 0) return padding.top + chartH / 2;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
const v =
|
|
35
|
+
followViewEdge && edgeValue
|
|
36
|
+
? edgeValue.value
|
|
37
|
+
: engine.displayValue.value;
|
|
38
|
+
return padding.top + ((dMax - v) / valRange) * chartH;
|
|
28
39
|
});
|
|
29
40
|
|
|
30
41
|
return { dotX, dotY } as const;
|
package/src/hooks/useMarkers.ts
CHANGED
|
@@ -10,16 +10,30 @@ import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
|
10
10
|
import type { ChartPadding } from "../draw/line";
|
|
11
11
|
import { projectMarkers, type ProjectedMarker } from "../math/markers";
|
|
12
12
|
import { nearestMarkerIndex } from "../math/markers";
|
|
13
|
+
import {
|
|
14
|
+
clusterMarkers,
|
|
15
|
+
clusterMembers,
|
|
16
|
+
type ResolvedMarkerCluster,
|
|
17
|
+
} from "../math/markerCluster";
|
|
13
18
|
import type {
|
|
14
19
|
LiveChartPoint,
|
|
15
20
|
Marker,
|
|
16
|
-
|
|
21
|
+
MarkerPressEvent,
|
|
17
22
|
SeriesConfig,
|
|
18
23
|
} from "../types";
|
|
19
24
|
|
|
25
|
+
const ANCHORED_CLUSTER: ResolvedMarkerCluster = {
|
|
26
|
+
mode: "anchored",
|
|
27
|
+
overlap: 0.75,
|
|
28
|
+
gap: 2,
|
|
29
|
+
maxBeforeGroup: 5,
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Projects markers to screen positions each frame and builds a tap gesture that
|
|
22
|
-
* hit-tests against them, firing `
|
|
34
|
+
* hit-tests against them, firing `onMarkerPress` (or `null` on a miss). Applies
|
|
35
|
+
* the {@link clusterMarkers} collision pass each frame so hit-testing matches the
|
|
36
|
+
* drawn (stacked / collapsed) positions.
|
|
23
37
|
*/
|
|
24
38
|
export function useMarkers(
|
|
25
39
|
engine: ChartEngineLayout,
|
|
@@ -27,7 +41,7 @@ export function useMarkers(
|
|
|
27
41
|
markers: SharedValue<Marker[]>,
|
|
28
42
|
active: boolean,
|
|
29
43
|
hitRadius: number,
|
|
30
|
-
|
|
44
|
+
onMarkerPress?: (event: MarkerPressEvent | null) => void,
|
|
31
45
|
seriesSV?: SharedValue<SeriesConfig[]>,
|
|
32
46
|
lineData?: SharedValue<LiveChartPoint[]>,
|
|
33
47
|
/** Static charts run no loops: register without starting. Default `true`. */
|
|
@@ -35,6 +49,8 @@ export function useMarkers(
|
|
|
35
49
|
/** Single-series line is drawn linear (`line.curve === "linear"`) — anchor
|
|
36
50
|
* `lineData` markers on the straight chord rather than the spline. */
|
|
37
51
|
lineLinear = false,
|
|
52
|
+
/** Collision config; default `"anchored"` (no stacking). */
|
|
53
|
+
cluster: ResolvedMarkerCluster = ANCHORED_CLUSTER,
|
|
38
54
|
): {
|
|
39
55
|
projected: SharedValue<ProjectedMarker[]>;
|
|
40
56
|
tapGesture: ReturnType<typeof Gesture.Tap>;
|
|
@@ -51,10 +67,10 @@ export function useMarkers(
|
|
|
51
67
|
cacheRef.current = { a: [] as ProjectedMarker[], b: [] as ProjectedMarker[], tick: false };
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
const
|
|
70
|
+
const emitPress =
|
|
55
71
|
/* istanbul ignore next -- invoked only from the UI-thread tap worklet */
|
|
56
|
-
(event:
|
|
57
|
-
|
|
72
|
+
(event: MarkerPressEvent | null) => {
|
|
73
|
+
onMarkerPress?.(event);
|
|
58
74
|
};
|
|
59
75
|
|
|
60
76
|
useFrameCallback(
|
|
@@ -82,6 +98,7 @@ export function useMarkers(
|
|
|
82
98
|
lineData: lineData?.get(),
|
|
83
99
|
lineLinear,
|
|
84
100
|
});
|
|
101
|
+
clusterMarkers(markers.get(), buf, { config: cluster });
|
|
85
102
|
projected.set(buf);
|
|
86
103
|
},
|
|
87
104
|
autostart,
|
|
@@ -92,14 +109,25 @@ export function useMarkers(
|
|
|
92
109
|
e,
|
|
93
110
|
) => {
|
|
94
111
|
"worklet";
|
|
95
|
-
const
|
|
112
|
+
const proj = projected.get();
|
|
113
|
+
const idx = nearestMarkerIndex(proj, e.x, e.y, hitRadius);
|
|
96
114
|
if (idx < 0) {
|
|
97
|
-
runOnJS(
|
|
115
|
+
runOnJS(emitPress)(null);
|
|
98
116
|
return;
|
|
99
117
|
}
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
118
|
+
const ms = markers.get();
|
|
119
|
+
const m = ms[idx];
|
|
120
|
+
const p = proj[idx];
|
|
121
|
+
const isGrouped = p.isGrouped;
|
|
122
|
+
// Collapsed cluster: surface the whole bucket so the consumer can list it.
|
|
123
|
+
const members = isGrouped ? clusterMembers(ms, proj, idx) : undefined;
|
|
124
|
+
runOnJS(emitPress)({
|
|
125
|
+
marker: m,
|
|
126
|
+
point: { x: p.x, y: p.y },
|
|
127
|
+
index: idx,
|
|
128
|
+
isGrouped,
|
|
129
|
+
members,
|
|
130
|
+
});
|
|
103
131
|
},
|
|
104
132
|
);
|
|
105
133
|
|