panelui-native 0.49.0 → 0.53.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 +9 -1
- package/lib/module/components/candlestick-chart/index.js +1161 -0
- package/lib/module/components/candlestick-chart/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +73 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/context-menu/index.js +529 -0
- package/lib/module/components/context-menu/index.js.map +1 -0
- package/lib/module/components/menu/index.js +18 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/popover/index.js +71 -7
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/sortable/index.js +942 -0
- package/lib/module/components/sortable/index.js.map +1 -0
- package/lib/module/components/swipe/index.js +140 -5
- package/lib/module/components/swipe/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +56 -15
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +295 -31
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/icons/index.js +29 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +5 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/haptics.js +19 -0
- package/lib/module/utils/haptics.js.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
- package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts +20 -20
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +51 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/index.d.ts +248 -0
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -0
- package/lib/typescript/src/components/swipe/index.d.ts +35 -0
- package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +34 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +8 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/haptics.d.ts +14 -0
- package/lib/typescript/src/utils/haptics.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/candlestick-chart/index.tsx +1360 -0
- package/src/components/combobox/index.tsx +84 -6
- package/src/components/context-menu/index.tsx +658 -0
- package/src/components/menu/index.tsx +17 -10
- package/src/components/popover/index.tsx +94 -6
- package/src/components/sortable/index.tsx +1266 -0
- package/src/components/swipe/index.tsx +165 -3
- package/src/components/tabs/index.tsx +82 -16
- package/src/components/time-picker/index.tsx +330 -35
- package/src/icons/index.tsx +22 -0
- package/src/index.ts +39 -0
- package/src/utils/haptics.ts +21 -0
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CandlestickChart — four numbers a period, drawn as one mark.
|
|
5
|
+
*
|
|
6
|
+
* Composed the same way every chart here is: the grid, the candles, the axes
|
|
7
|
+
* and the readout are separate children, so a chart that wants no grid simply
|
|
8
|
+
* does not have one.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <CandlestickChart data={session} xDataKey="day">
|
|
12
|
+
* <CandlestickChart.Grid />
|
|
13
|
+
* <CandlestickChart.Candles />
|
|
14
|
+
* <CandlestickChart.XAxis />
|
|
15
|
+
* <CandlestickChart.Tooltip />
|
|
16
|
+
* </CandlestickChart>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* ## One mark, four numbers
|
|
20
|
+
*
|
|
21
|
+
* A row is `{ open, high, low, close }`. The body spans open to close and is
|
|
22
|
+
* filled by direction — rising when the close is at or above the open, falling
|
|
23
|
+
* when it is below — and the wick spans low to high behind it. Colour is
|
|
24
|
+
* therefore the *sign* of the period rather than a series identity, which is
|
|
25
|
+
* why this chart has no series list and no legend: there is only ever one
|
|
26
|
+
* thing plotted, and the two colours are its two states.
|
|
27
|
+
*
|
|
28
|
+
* ## The baseline is not zero, and must not be
|
|
29
|
+
*
|
|
30
|
+
* A bar compares lengths, so its axis has to reach zero or twice as tall stops
|
|
31
|
+
* meaning twice as much. A candle compares nothing to zero. What is being read
|
|
32
|
+
* is the distance between four numbers that sit close together and far from the
|
|
33
|
+
* origin — a share at 180 that moved between 178 and 183 is a chart of that
|
|
34
|
+
* five-point span, and forcing zero onto the axis turns every candle in it into
|
|
35
|
+
* a dash. So the domain is derived from the lowest low and the highest high,
|
|
36
|
+
* with a margin so the extremes are not drawn on the frame.
|
|
37
|
+
*
|
|
38
|
+
* ## Two paths, not two per candle
|
|
39
|
+
*
|
|
40
|
+
* Every rising body is a subpath of one path and every falling body of another,
|
|
41
|
+
* and the wicks likewise — four animated props a frame whether the chart holds
|
|
42
|
+
* twenty candles or two hundred. A candle also cannot be drawn as a rectangle
|
|
43
|
+
* element and animated: the grow-in scales each body about its own middle, and
|
|
44
|
+
* that is a path rebuilt per frame on the UI thread rather than a transform
|
|
45
|
+
* that would take the wick and the neighbours with it.
|
|
46
|
+
*/
|
|
47
|
+
import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
48
|
+
import { StyleSheet, View } from 'react-native';
|
|
49
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
50
|
+
import Animated, { Easing, runOnJS, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
51
|
+
import Svg, { G, Line as SvgLine, Path } from 'react-native-svg';
|
|
52
|
+
import { useCSSVariable } from 'uniwind';
|
|
53
|
+
import { Text } from "../../primitives/text.js";
|
|
54
|
+
import { bandOf, compactNumber, yOf } from "../../utils/chart.js";
|
|
55
|
+
import { cn } from "../../utils/cn.js";
|
|
56
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
57
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
58
|
+
|
|
59
|
+
/** Room left around the plot for the axis labels. */
|
|
60
|
+
const PADDING = {
|
|
61
|
+
top: 12,
|
|
62
|
+
right: 10,
|
|
63
|
+
bottom: 22,
|
|
64
|
+
left: 10
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Width the readout is laid out at, so it can be clamped inside the plot. */
|
|
68
|
+
const LABEL_WIDTH = 148;
|
|
69
|
+
|
|
70
|
+
/** Left gutter reserved when a `YAxis` is present, for its labels to sit in. */
|
|
71
|
+
const Y_AXIS_WIDTH = 48;
|
|
72
|
+
|
|
73
|
+
/** Line height of an `xs` label, for centring one on the grid line it names. */
|
|
74
|
+
const AXIS_LABEL_HEIGHT = 16;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Closest two date labels may be placed, in points.
|
|
78
|
+
*
|
|
79
|
+
* Wider than the text needs, and that is the whole job of it. A candlestick
|
|
80
|
+
* chart holds far more periods than a bar chart holds categories — thirty
|
|
81
|
+
* sessions across a phone is eleven points each — so the axis is always
|
|
82
|
+
* thinning, and how much it thins is set here. Spacing them at the width of the
|
|
83
|
+
* text alone leaves every label touching its neighbour, which reads as one long
|
|
84
|
+
* smear rather than as a row of dates.
|
|
85
|
+
*/
|
|
86
|
+
const MIN_BAND_LABEL = 68;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Width one date label is drawn in.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately narrower than the spacing above, so there is always a gap
|
|
92
|
+
* between one label and the next. It is also what each is centred in, so a
|
|
93
|
+
* label sits over its own candle rather than over the slice it shares.
|
|
94
|
+
*/
|
|
95
|
+
const LABEL_BOX = 52;
|
|
96
|
+
|
|
97
|
+
/** Thinnest a wick is drawn, in points. Below about this it stops being visible. */
|
|
98
|
+
const WICK_WIDTH = 1.5;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Shortest a body is drawn, in points.
|
|
102
|
+
*
|
|
103
|
+
* A period that opened and closed at the same number is the one case where the
|
|
104
|
+
* body has no height of its own, and it is not the same event as no period at
|
|
105
|
+
* all — a flat body says the price came back to where it started, which is
|
|
106
|
+
* worth seeing. It is also what a doji is.
|
|
107
|
+
*/
|
|
108
|
+
const MIN_BODY_HEIGHT = 1.5;
|
|
109
|
+
/** Green and red before the stylesheet has been read. */
|
|
110
|
+
const FALLBACK_RISING = '#10b981';
|
|
111
|
+
const FALLBACK_FALLING = '#ef4444';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A candle's colour, which is its *direction* rather than a series identity.
|
|
115
|
+
*
|
|
116
|
+
* The chart-series tokens are wrong here on purpose. They are a palette meant
|
|
117
|
+
* to tell several things apart, and are picked to be distinguishable rather
|
|
118
|
+
* than to mean anything; up and down are not two series, they are two states of
|
|
119
|
+
* one, and the convention for them is older than any palette. So this reaches
|
|
120
|
+
* for the success and destructive tokens, which are the theme's green and red
|
|
121
|
+
* and already carry that sense everywhere else in the library.
|
|
122
|
+
*/
|
|
123
|
+
function useDirectionColor(explicit, direction) {
|
|
124
|
+
const token = useCSSVariable(direction === 'rising' ? '--color-success' : '--color-destructive');
|
|
125
|
+
const fallback = direction === 'rising' ? FALLBACK_RISING : FALLBACK_FALLING;
|
|
126
|
+
return explicit ?? (typeof token === 'string' ? token : fallback);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One period. `open`, `high`, `low` and `close` are required to be numbers —
|
|
131
|
+
* unlike a line, a candle cannot be drawn from some of them.
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
const CandlestickChartContext = /*#__PURE__*/createContext(null);
|
|
135
|
+
function useChart(component) {
|
|
136
|
+
const context = useContext(CandlestickChartContext);
|
|
137
|
+
if (!context) {
|
|
138
|
+
throw new Error(`${component} must be used within a <CandlestickChart>`);
|
|
139
|
+
}
|
|
140
|
+
return context;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The candle under the finger, for something rendered *inside* the chart. A
|
|
145
|
+
* readout in the card's header is outside this provider — use
|
|
146
|
+
* `onActiveIndexChange` for that.
|
|
147
|
+
*/
|
|
148
|
+
export function useCandlestickChart() {
|
|
149
|
+
const {
|
|
150
|
+
data,
|
|
151
|
+
activeIndexJS,
|
|
152
|
+
xDataKey
|
|
153
|
+
} = useChart('useCandlestickChart');
|
|
154
|
+
return {
|
|
155
|
+
activeIndex: activeIndexJS,
|
|
156
|
+
activePoint: activeIndexJS >= 0 ? data[activeIndexJS] ?? null : null,
|
|
157
|
+
xDataKey
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Reads one row's four numbers, or `null` if any of them is missing. */
|
|
162
|
+
function ohlcOf(row, openKey, highKey, lowKey, closeKey) {
|
|
163
|
+
'worklet';
|
|
164
|
+
|
|
165
|
+
if (!row) return null;
|
|
166
|
+
const open = row[openKey];
|
|
167
|
+
const high = row[highKey];
|
|
168
|
+
const low = row[lowKey];
|
|
169
|
+
const close = row[closeKey];
|
|
170
|
+
if (typeof open !== 'number' || typeof high !== 'number' || typeof low !== 'number' || typeof close !== 'number' || Number.isNaN(open) || Number.isNaN(high) || Number.isNaN(low) || Number.isNaN(close)) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
open,
|
|
175
|
+
high,
|
|
176
|
+
low,
|
|
177
|
+
close
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Imperative handle: re-run the grow-in, for a "replay" control. */
|
|
182
|
+
|
|
183
|
+
function partition(children) {
|
|
184
|
+
const svg = [];
|
|
185
|
+
const overlay = [];
|
|
186
|
+
const header = [];
|
|
187
|
+
Children.forEach(children, (child, index) => {
|
|
188
|
+
if (! /*#__PURE__*/isValidElement(child)) return;
|
|
189
|
+
const layer = child.type.layer ?? 'svg';
|
|
190
|
+
const slot = /*#__PURE__*/_jsx(ChildSlot, {
|
|
191
|
+
children: child
|
|
192
|
+
}, index);
|
|
193
|
+
(layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(slot);
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
svg,
|
|
197
|
+
overlay,
|
|
198
|
+
header
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Identity wrapper, purely so the partitioned arrays can carry keys. */
|
|
203
|
+
function ChildSlot({
|
|
204
|
+
children
|
|
205
|
+
}) {
|
|
206
|
+
return /*#__PURE__*/_jsx(_Fragment, {
|
|
207
|
+
children: children
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const CandlestickChartRoot = /*#__PURE__*/forwardRef(function CandlestickChartRoot({
|
|
211
|
+
className,
|
|
212
|
+
data,
|
|
213
|
+
xDataKey = 'date',
|
|
214
|
+
openDataKey = 'open',
|
|
215
|
+
highDataKey = 'high',
|
|
216
|
+
lowDataKey = 'low',
|
|
217
|
+
closeDataKey = 'close',
|
|
218
|
+
status = 'ready',
|
|
219
|
+
aspectRatio = 1.6,
|
|
220
|
+
animationDuration = 1100,
|
|
221
|
+
domainDuration = 500,
|
|
222
|
+
yDomain,
|
|
223
|
+
candleGap = 0.3,
|
|
224
|
+
candleWidth,
|
|
225
|
+
fadedOpacity = 0.3,
|
|
226
|
+
onActiveIndexChange,
|
|
227
|
+
compact = false,
|
|
228
|
+
children,
|
|
229
|
+
...props
|
|
230
|
+
}, ref) {
|
|
231
|
+
const [size, setSize] = useState({
|
|
232
|
+
width: 0,
|
|
233
|
+
height: 0
|
|
234
|
+
});
|
|
235
|
+
const [activeIndexJS, setActiveIndexJS] = useState(-1);
|
|
236
|
+
const reveal = useSharedValue(0);
|
|
237
|
+
const domainMin = useSharedValue(0);
|
|
238
|
+
const domainMax = useSharedValue(0);
|
|
239
|
+
const activeIndex = useSharedValue(-1);
|
|
240
|
+
const reducedMotion = useReducedMotion();
|
|
241
|
+
|
|
242
|
+
/*
|
|
243
|
+
* Whether an axis is asking for room. It has to be known before the plot is
|
|
244
|
+
* laid out, and only the root sees the children early enough to ask — the
|
|
245
|
+
* axis itself renders into a box that has already been decided.
|
|
246
|
+
*/
|
|
247
|
+
const hasYAxis = useMemo(() => {
|
|
248
|
+
let found = false;
|
|
249
|
+
Children.forEach(children, child => {
|
|
250
|
+
if (/*#__PURE__*/isValidElement(child) && child.type.axis === 'y') {
|
|
251
|
+
found = true;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return found;
|
|
255
|
+
}, [children]);
|
|
256
|
+
const pad = compact ? {
|
|
257
|
+
top: 2,
|
|
258
|
+
right: 1,
|
|
259
|
+
bottom: 2,
|
|
260
|
+
left: 1
|
|
261
|
+
} : {
|
|
262
|
+
...PADDING,
|
|
263
|
+
left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left
|
|
264
|
+
};
|
|
265
|
+
const plot = {
|
|
266
|
+
left: pad.left,
|
|
267
|
+
top: pad.top,
|
|
268
|
+
width: Math.max(size.width - pad.left - pad.right, 0),
|
|
269
|
+
height: Math.max(size.height - pad.top - pad.bottom, 0)
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/*
|
|
273
|
+
* The lowest low to the highest high, with a tenth of the span as margin at
|
|
274
|
+
* each end so the extremes are not drawn on the frame.
|
|
275
|
+
*
|
|
276
|
+
* Nothing here reaches for zero, and that is the point. A candle's four
|
|
277
|
+
* numbers sit close together and usually far from the origin; including
|
|
278
|
+
* zero would compress the whole chart into a band at the top and every
|
|
279
|
+
* candle in it into a dash.
|
|
280
|
+
*/
|
|
281
|
+
const extent = useMemo(() => {
|
|
282
|
+
if (yDomain) return yDomain;
|
|
283
|
+
let min = Number.POSITIVE_INFINITY;
|
|
284
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
285
|
+
for (const row of data) {
|
|
286
|
+
const ohlc = ohlcOf(row, openDataKey, highDataKey, lowDataKey, closeDataKey);
|
|
287
|
+
if (!ohlc) continue;
|
|
288
|
+
if (ohlc.low < min) min = ohlc.low;
|
|
289
|
+
if (ohlc.high > max) max = ohlc.high;
|
|
290
|
+
}
|
|
291
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
|
|
292
|
+
// A span of nothing — one period, or a price that never moved — still
|
|
293
|
+
// needs a domain to divide by, so it is given a little room either side.
|
|
294
|
+
if (min === max) {
|
|
295
|
+
const nudge = Math.abs(min) * 0.05 || 1;
|
|
296
|
+
return [min - nudge, max + nudge];
|
|
297
|
+
}
|
|
298
|
+
const margin = (max - min) * 0.1;
|
|
299
|
+
return [min - margin, max + margin];
|
|
300
|
+
}, [data, yDomain, openDataKey, highDataKey, lowDataKey, closeDataKey]);
|
|
301
|
+
const loading = status === 'loading';
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (loading) return;
|
|
304
|
+
const [min, max] = extent;
|
|
305
|
+
const first = domainMin.value === 0 && domainMax.value === 0;
|
|
306
|
+
if (first || reducedMotion) {
|
|
307
|
+
domainMin.value = min;
|
|
308
|
+
domainMax.value = max;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
domainMin.value = withTiming(min, {
|
|
312
|
+
duration: domainDuration
|
|
313
|
+
});
|
|
314
|
+
domainMax.value = withTiming(max, {
|
|
315
|
+
duration: domainDuration
|
|
316
|
+
});
|
|
317
|
+
}, [extent, loading, reducedMotion, domainDuration, domainMin, domainMax]);
|
|
318
|
+
const revealed = useRef(false);
|
|
319
|
+
const playReveal = useMemo(() => () => {
|
|
320
|
+
if (reducedMotion) {
|
|
321
|
+
reveal.value = 1;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
reveal.value = 0;
|
|
325
|
+
reveal.value = withTiming(1, {
|
|
326
|
+
duration: animationDuration,
|
|
327
|
+
easing: Easing.bezier(0.85, 0, 0.15, 1)
|
|
328
|
+
});
|
|
329
|
+
}, [reducedMotion, animationDuration, reveal]);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (revealed.current || loading || plot.width <= 0 || !data.length) return;
|
|
332
|
+
revealed.current = true;
|
|
333
|
+
playReveal();
|
|
334
|
+
}, [loading, plot.width, data.length, playReveal]);
|
|
335
|
+
useImperativeHandle(ref, () => ({
|
|
336
|
+
replay: playReveal
|
|
337
|
+
}), [playReveal]);
|
|
338
|
+
const handleActiveIndex = useMemo(() => index => {
|
|
339
|
+
setActiveIndexJS(index);
|
|
340
|
+
onActiveIndexChange?.(index, index >= 0 ? data[index] ?? null : null);
|
|
341
|
+
}, [onActiveIndexChange, data]);
|
|
342
|
+
const onLayout = event => {
|
|
343
|
+
const {
|
|
344
|
+
width,
|
|
345
|
+
height
|
|
346
|
+
} = event.nativeEvent.layout;
|
|
347
|
+
setSize(current => Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1 ? current : {
|
|
348
|
+
width,
|
|
349
|
+
height
|
|
350
|
+
});
|
|
351
|
+
props.onLayout?.(event);
|
|
352
|
+
};
|
|
353
|
+
const context = useMemo(() => ({
|
|
354
|
+
data,
|
|
355
|
+
xDataKey,
|
|
356
|
+
openKey: openDataKey,
|
|
357
|
+
highKey: highDataKey,
|
|
358
|
+
lowKey: lowDataKey,
|
|
359
|
+
closeKey: closeDataKey,
|
|
360
|
+
plot,
|
|
361
|
+
status,
|
|
362
|
+
candleGap,
|
|
363
|
+
candleWidth,
|
|
364
|
+
fadedOpacity,
|
|
365
|
+
domainMin,
|
|
366
|
+
domainMax,
|
|
367
|
+
extent,
|
|
368
|
+
reveal,
|
|
369
|
+
activeIndex,
|
|
370
|
+
activeIndexJS,
|
|
371
|
+
setActiveIndexJS: handleActiveIndex
|
|
372
|
+
}),
|
|
373
|
+
// `plot` is rebuilt every render from `size`, so it is compared by value.
|
|
374
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
375
|
+
[data, xDataKey, openDataKey, highDataKey, lowDataKey, closeDataKey, plot.width, plot.height, plot.left, plot.top, status, candleGap, candleWidth, fadedOpacity, domainMin, domainMax, extent, reveal, activeIndex, activeIndexJS, handleActiveIndex]);
|
|
376
|
+
const {
|
|
377
|
+
svg,
|
|
378
|
+
overlay,
|
|
379
|
+
header
|
|
380
|
+
} = partition(children);
|
|
381
|
+
|
|
382
|
+
/*
|
|
383
|
+
* Two views, because the header is not part of the plot. `aspectRatio` and
|
|
384
|
+
* the layout measurement belong to the drawing area alone — measured on the
|
|
385
|
+
* outer view they would take in the header too, and the plot would lose as
|
|
386
|
+
* much height as the readout took while still claiming the shape asked for.
|
|
387
|
+
*/
|
|
388
|
+
return /*#__PURE__*/_jsx(CandlestickChartContext.Provider, {
|
|
389
|
+
value: context,
|
|
390
|
+
children: /*#__PURE__*/_jsxs(View, {
|
|
391
|
+
...props,
|
|
392
|
+
style: props.style,
|
|
393
|
+
className: cn('w-full', className),
|
|
394
|
+
children: [header, /*#__PURE__*/_jsx(View, {
|
|
395
|
+
onLayout: onLayout,
|
|
396
|
+
style: {
|
|
397
|
+
aspectRatio
|
|
398
|
+
},
|
|
399
|
+
className: "w-full",
|
|
400
|
+
children: plot.width > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
401
|
+
children: [/*#__PURE__*/_jsx(Svg, {
|
|
402
|
+
width: "100%",
|
|
403
|
+
height: "100%",
|
|
404
|
+
style: StyleSheet.absoluteFill,
|
|
405
|
+
children: svg
|
|
406
|
+
}), overlay]
|
|
407
|
+
}) : null
|
|
408
|
+
})]
|
|
409
|
+
})
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
CandlestickChartRoot.displayName = 'CandlestickChart';
|
|
413
|
+
|
|
414
|
+
/* -------------------------------------------------------------------------- */
|
|
415
|
+
/* SVG layer */
|
|
416
|
+
/* -------------------------------------------------------------------------- */
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Lines across the price axis, so a candle can be read against a number rather
|
|
420
|
+
* than only against the candle beside it.
|
|
421
|
+
*/
|
|
422
|
+
function CandlestickChartGrid({
|
|
423
|
+
rows = 4,
|
|
424
|
+
color,
|
|
425
|
+
dashArray = '4,6',
|
|
426
|
+
opacity = 1
|
|
427
|
+
}) {
|
|
428
|
+
const {
|
|
429
|
+
plot
|
|
430
|
+
} = useChart('CandlestickChart.Grid');
|
|
431
|
+
const token = useCSSVariable('--color-border');
|
|
432
|
+
const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
433
|
+
return /*#__PURE__*/_jsx(G, {
|
|
434
|
+
opacity: opacity,
|
|
435
|
+
children: Array.from({
|
|
436
|
+
length: rows + 1
|
|
437
|
+
}, (_unused, index) => {
|
|
438
|
+
const y = plot.top + plot.height / rows * index;
|
|
439
|
+
return /*#__PURE__*/_jsx(SvgLine, {
|
|
440
|
+
x1: plot.left,
|
|
441
|
+
x2: plot.left + plot.width,
|
|
442
|
+
y1: y,
|
|
443
|
+
y2: y,
|
|
444
|
+
stroke: stroke,
|
|
445
|
+
strokeWidth: 1,
|
|
446
|
+
strokeDasharray: dashArray
|
|
447
|
+
}, index);
|
|
448
|
+
})
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
CandlestickChartGrid.displayName = 'CandlestickChart.Grid';
|
|
452
|
+
CandlestickChartGrid.layer = 'svg';
|
|
453
|
+
/**
|
|
454
|
+
* The candles.
|
|
455
|
+
*
|
|
456
|
+
* Four paths, not four per candle: rising bodies, falling bodies, and the wicks
|
|
457
|
+
* behind each. A chart of two hundred periods is the same four animated props
|
|
458
|
+
* a frame as a chart of twenty, and the split by direction is what lets each
|
|
459
|
+
* half carry its own fill without a fill per mark.
|
|
460
|
+
*
|
|
461
|
+
* The wicks are drawn first so the bodies sit over them, which is what makes a
|
|
462
|
+
* body with a wick behind it read as one mark rather than as a line crossing a
|
|
463
|
+
* rectangle.
|
|
464
|
+
*/
|
|
465
|
+
function CandlestickChartCandles({
|
|
466
|
+
risingColor,
|
|
467
|
+
fallingColor,
|
|
468
|
+
cornerRadius = 1.5
|
|
469
|
+
}) {
|
|
470
|
+
const {
|
|
471
|
+
data,
|
|
472
|
+
openKey,
|
|
473
|
+
highKey,
|
|
474
|
+
lowKey,
|
|
475
|
+
closeKey,
|
|
476
|
+
plot,
|
|
477
|
+
candleGap,
|
|
478
|
+
candleWidth,
|
|
479
|
+
fadedOpacity,
|
|
480
|
+
domainMin,
|
|
481
|
+
domainMax,
|
|
482
|
+
reveal,
|
|
483
|
+
activeIndex,
|
|
484
|
+
status
|
|
485
|
+
} = useChart('CandlestickChart.Candles');
|
|
486
|
+
const rising = useDirectionColor(risingColor, 'rising');
|
|
487
|
+
const falling = useDirectionColor(fallingColor, 'falling');
|
|
488
|
+
const total = data.length;
|
|
489
|
+
const slice = total > 0 ? plot.width / total : 0;
|
|
490
|
+
const width = Math.max(1, Math.min(candleWidth ?? slice * (1 - candleGap), Math.max(slice - 1, 1)));
|
|
491
|
+
|
|
492
|
+
/*
|
|
493
|
+
* Pulled out of the worklet as plain arrays. A worklet may close over numbers
|
|
494
|
+
* and arrays of them freely, but reading `data`'s rows — objects of mixed
|
|
495
|
+
* types, keyed by strings the caller chose — inside one every frame is the
|
|
496
|
+
* kind of work that belongs on the JS side once rather than on the UI thread
|
|
497
|
+
* sixty times a second.
|
|
498
|
+
*/
|
|
499
|
+
const columns = useMemo(() => {
|
|
500
|
+
const opens = [];
|
|
501
|
+
const highs = [];
|
|
502
|
+
const lows = [];
|
|
503
|
+
const closes = [];
|
|
504
|
+
const usable = [];
|
|
505
|
+
for (const row of data) {
|
|
506
|
+
const ohlc = ohlcOf(row, openKey, highKey, lowKey, closeKey);
|
|
507
|
+
opens.push(ohlc?.open ?? 0);
|
|
508
|
+
highs.push(ohlc?.high ?? 0);
|
|
509
|
+
lows.push(ohlc?.low ?? 0);
|
|
510
|
+
closes.push(ohlc?.close ?? 0);
|
|
511
|
+
usable.push(ohlc !== null);
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
opens,
|
|
515
|
+
highs,
|
|
516
|
+
lows,
|
|
517
|
+
closes,
|
|
518
|
+
usable
|
|
519
|
+
};
|
|
520
|
+
}, [data, openKey, highKey, lowKey, closeKey]);
|
|
521
|
+
|
|
522
|
+
/*
|
|
523
|
+
* The four builders share this. Declared as a worklet at this scope so each
|
|
524
|
+
* `useAnimatedProps` below can call it — a worklet may only call another
|
|
525
|
+
* worklet, and the rule is enforced by crashing rather than by warning.
|
|
526
|
+
*/
|
|
527
|
+
const build = (wantRising, wicks) => () => {
|
|
528
|
+
'worklet';
|
|
529
|
+
|
|
530
|
+
const min = domainMin.value;
|
|
531
|
+
const max = domainMax.value;
|
|
532
|
+
if (max === min || total === 0 || plot.width <= 0) return {
|
|
533
|
+
d: ''
|
|
534
|
+
};
|
|
535
|
+
const grow = reveal.value;
|
|
536
|
+
let path = '';
|
|
537
|
+
for (let index = 0; index < total; index += 1) {
|
|
538
|
+
if (!columns.usable[index]) continue;
|
|
539
|
+
const open = columns.opens[index] ?? 0;
|
|
540
|
+
const close = columns.closes[index] ?? 0;
|
|
541
|
+
const up = close >= open;
|
|
542
|
+
if (up !== wantRising) continue;
|
|
543
|
+
|
|
544
|
+
/*
|
|
545
|
+
* Staggered, so the chart fills in left to right rather than every
|
|
546
|
+
* candle inflating at once. Each one gets the back half of the run,
|
|
547
|
+
* which leaves the last candle finishing with the animation.
|
|
548
|
+
*/
|
|
549
|
+
const start = total > 1 ? index / total * 0.45 : 0;
|
|
550
|
+
const eased = Math.max(0, Math.min(1, (grow - start) / 0.55));
|
|
551
|
+
if (eased <= 0) continue;
|
|
552
|
+
const centre = bandOf(index, total, plot);
|
|
553
|
+
const half = width / 2;
|
|
554
|
+
if (wicks) {
|
|
555
|
+
const high = columns.highs[index] ?? 0;
|
|
556
|
+
const low = columns.lows[index] ?? 0;
|
|
557
|
+
const yHigh = yOf(high, plot, min, max);
|
|
558
|
+
const yLow = yOf(low, plot, min, max);
|
|
559
|
+
// Grown about the body's middle rather than from the low, so the wick
|
|
560
|
+
// opens out in both directions the way the body does.
|
|
561
|
+
const mid = (yHigh + yLow) / 2;
|
|
562
|
+
const top = mid + (yHigh - mid) * eased;
|
|
563
|
+
const bottom = mid + (yLow - mid) * eased;
|
|
564
|
+
const x = centre - WICK_WIDTH / 2;
|
|
565
|
+
path += `M${x} ${top}h${WICK_WIDTH}V${bottom}h${-WICK_WIDTH}Z`;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const yOpen = yOf(open, plot, min, max);
|
|
569
|
+
const yClose = yOf(close, plot, min, max);
|
|
570
|
+
const bodyTop = Math.min(yOpen, yClose);
|
|
571
|
+
const bodyBottom = Math.max(yOpen, yClose);
|
|
572
|
+
const height = Math.max(bodyBottom - bodyTop, MIN_BODY_HEIGHT);
|
|
573
|
+
const mid = bodyTop + height / 2;
|
|
574
|
+
const grown = height * eased;
|
|
575
|
+
const top = mid - grown / 2;
|
|
576
|
+
|
|
577
|
+
// The radius can never exceed half the shortest side, or the two corners
|
|
578
|
+
// on one edge overlap and the arc turns itself inside out.
|
|
579
|
+
const r = Math.max(0, Math.min(cornerRadius, width / 2, grown / 2));
|
|
580
|
+
const x = centre - half;
|
|
581
|
+
if (r <= 0) {
|
|
582
|
+
path += `M${x} ${top}h${width}v${grown}h${-width}Z`;
|
|
583
|
+
} else {
|
|
584
|
+
path += `M${x + r} ${top}` + `h${width - r * 2}a${r} ${r} 0 0 1 ${r} ${r}` + `v${grown - r * 2}a${r} ${r} 0 0 1 ${-r} ${r}` + `h${-(width - r * 2)}a${r} ${r} 0 0 1 ${-r} ${-r}` + `v${-(grown - r * 2)}a${r} ${r} 0 0 1 ${r} ${-r}Z`;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
d: path
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/*
|
|
593
|
+
* The builder is curried and its result handed straight to
|
|
594
|
+
* `useAnimatedProps`, rather than being called from inside another worklet.
|
|
595
|
+
* That is not a style choice: the hook works out what to re-run on by reading
|
|
596
|
+
* the closure of the worklet it is given, and a shared value read one level
|
|
597
|
+
* down is not in that closure. Wrapped, the paths were built once and then
|
|
598
|
+
* never again — the chart drew nothing until some unrelated re-render
|
|
599
|
+
* happened to rebuild them, which looked like a chart that only appeared once
|
|
600
|
+
* it was touched.
|
|
601
|
+
*/
|
|
602
|
+
const risingBodies = useAnimatedProps(build(true, false));
|
|
603
|
+
const fallingBodies = useAnimatedProps(build(false, false));
|
|
604
|
+
const risingWicks = useAnimatedProps(build(true, true));
|
|
605
|
+
const fallingWicks = useAnimatedProps(build(false, true));
|
|
606
|
+
|
|
607
|
+
/*
|
|
608
|
+
* Dimming is a group opacity driven from the active index, rather than a
|
|
609
|
+
* second pair of paths for the candle under the finger. A candle is one mark
|
|
610
|
+
* of one colour, so there is nothing to keep at full ink separately — the
|
|
611
|
+
* whole group fades and the active candle is redrawn over it at full
|
|
612
|
+
* strength by `Highlight` below.
|
|
613
|
+
*/
|
|
614
|
+
const dim = useAnimatedProps(() => ({
|
|
615
|
+
opacity: activeIndex.value >= 0 ? fadedOpacity : 1
|
|
616
|
+
}));
|
|
617
|
+
if (status === 'loading') return null;
|
|
618
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
619
|
+
children: [/*#__PURE__*/_jsxs(AnimatedGroup, {
|
|
620
|
+
animatedProps: dim,
|
|
621
|
+
children: [/*#__PURE__*/_jsx(AnimatedPath, {
|
|
622
|
+
animatedProps: risingWicks,
|
|
623
|
+
fill: rising
|
|
624
|
+
}), /*#__PURE__*/_jsx(AnimatedPath, {
|
|
625
|
+
animatedProps: fallingWicks,
|
|
626
|
+
fill: falling
|
|
627
|
+
}), /*#__PURE__*/_jsx(AnimatedPath, {
|
|
628
|
+
animatedProps: risingBodies,
|
|
629
|
+
fill: rising
|
|
630
|
+
}), /*#__PURE__*/_jsx(AnimatedPath, {
|
|
631
|
+
animatedProps: fallingBodies,
|
|
632
|
+
fill: falling
|
|
633
|
+
})]
|
|
634
|
+
}), /*#__PURE__*/_jsx(CandlestickHighlight, {
|
|
635
|
+
rising: rising,
|
|
636
|
+
falling: falling,
|
|
637
|
+
width: width,
|
|
638
|
+
cornerRadius: cornerRadius,
|
|
639
|
+
columns: columns
|
|
640
|
+
})]
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
CandlestickChartCandles.displayName = 'CandlestickChart.Candles';
|
|
644
|
+
CandlestickChartCandles.layer = 'svg';
|
|
645
|
+
const AnimatedGroup = Animated.createAnimatedComponent(G);
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The candle under the finger, drawn again at full strength over the dimmed
|
|
649
|
+
* rest.
|
|
650
|
+
*
|
|
651
|
+
* Redrawing one mark is cheaper than splitting every path in two and rebuilding
|
|
652
|
+
* both whenever the finger moves to the next candle, and it keeps the grow-in
|
|
653
|
+
* builder above to a single shape.
|
|
654
|
+
*/
|
|
655
|
+
function CandlestickHighlight({
|
|
656
|
+
rising,
|
|
657
|
+
falling,
|
|
658
|
+
width,
|
|
659
|
+
cornerRadius,
|
|
660
|
+
columns
|
|
661
|
+
}) {
|
|
662
|
+
const {
|
|
663
|
+
data,
|
|
664
|
+
plot,
|
|
665
|
+
domainMin,
|
|
666
|
+
domainMax,
|
|
667
|
+
activeIndex
|
|
668
|
+
} = useChart('CandlestickChart.Candles');
|
|
669
|
+
const total = data.length;
|
|
670
|
+
|
|
671
|
+
/*
|
|
672
|
+
* The fill cannot be crossfaded between two colours on the UI thread, so both
|
|
673
|
+
* paths are drawn and the one that does not apply is emptied. An empty path
|
|
674
|
+
* costs nothing to render, and the swap never touches JS.
|
|
675
|
+
*/
|
|
676
|
+
const build = wantRising => () => {
|
|
677
|
+
'worklet';
|
|
678
|
+
|
|
679
|
+
const index = activeIndex.value;
|
|
680
|
+
const min = domainMin.value;
|
|
681
|
+
const max = domainMax.value;
|
|
682
|
+
if (index < 0 || index >= total || max === min || !columns.usable[index]) {
|
|
683
|
+
return {
|
|
684
|
+
d: ''
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const open = columns.opens[index] ?? 0;
|
|
688
|
+
const close = columns.closes[index] ?? 0;
|
|
689
|
+
if (close >= open !== wantRising) return {
|
|
690
|
+
d: ''
|
|
691
|
+
};
|
|
692
|
+
const high = columns.highs[index] ?? 0;
|
|
693
|
+
const low = columns.lows[index] ?? 0;
|
|
694
|
+
const centre = bandOf(index, total, plot);
|
|
695
|
+
const yOpen = yOf(open, plot, min, max);
|
|
696
|
+
const yClose = yOf(close, plot, min, max);
|
|
697
|
+
const bodyTop = Math.min(yOpen, yClose);
|
|
698
|
+
const height = Math.max(Math.abs(yClose - yOpen), MIN_BODY_HEIGHT);
|
|
699
|
+
const x = centre - width / 2;
|
|
700
|
+
const r = Math.max(0, Math.min(cornerRadius, width / 2, height / 2));
|
|
701
|
+
const wickX = centre - WICK_WIDTH / 2;
|
|
702
|
+
const wick = `M${wickX} ${yOf(high, plot, min, max)}h${WICK_WIDTH}` + `V${yOf(low, plot, min, max)}h${-WICK_WIDTH}Z`;
|
|
703
|
+
const body = r <= 0 ? `M${x} ${bodyTop}h${width}v${height}h${-width}Z` : `M${x + r} ${bodyTop}` + `h${width - r * 2}a${r} ${r} 0 0 1 ${r} ${r}` + `v${height - r * 2}a${r} ${r} 0 0 1 ${-r} ${r}` + `h${-(width - r * 2)}a${r} ${r} 0 0 1 ${-r} ${-r}` + `v${-(height - r * 2)}a${r} ${r} 0 0 1 ${r} ${-r}Z`;
|
|
704
|
+
return {
|
|
705
|
+
d: `${wick}${body}`
|
|
706
|
+
};
|
|
707
|
+
};
|
|
708
|
+
const risingShape = useAnimatedProps(build(true));
|
|
709
|
+
const fallingShape = useAnimatedProps(build(false));
|
|
710
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
711
|
+
children: [/*#__PURE__*/_jsx(AnimatedPath, {
|
|
712
|
+
animatedProps: risingShape,
|
|
713
|
+
fill: rising
|
|
714
|
+
}), /*#__PURE__*/_jsx(AnimatedPath, {
|
|
715
|
+
animatedProps: fallingShape,
|
|
716
|
+
fill: falling
|
|
717
|
+
})]
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/* -------------------------------------------------------------------------- */
|
|
722
|
+
/* Overlay layer */
|
|
723
|
+
/* -------------------------------------------------------------------------- */
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The period labels, one under each candle it has room for. Real text rather
|
|
727
|
+
* than SVG text, so they follow the theme's font and the platform's text
|
|
728
|
+
* scaling — SVG text does neither.
|
|
729
|
+
*/
|
|
730
|
+
function CandlestickChartXAxis({
|
|
731
|
+
ticks,
|
|
732
|
+
format,
|
|
733
|
+
className
|
|
734
|
+
}) {
|
|
735
|
+
const {
|
|
736
|
+
data,
|
|
737
|
+
xDataKey,
|
|
738
|
+
plot
|
|
739
|
+
} = useChart('CandlestickChart.XAxis');
|
|
740
|
+
const labels = useMemo(() => {
|
|
741
|
+
if (!data.length) return [];
|
|
742
|
+
|
|
743
|
+
// The axis asks the plot how much room there is and only thins when the
|
|
744
|
+
// answer is not enough, rather than dropping labels that would have fitted.
|
|
745
|
+
const room = Math.max(1, Math.floor(plot.width / MIN_BAND_LABEL));
|
|
746
|
+
const count = Math.max(1, Math.min(ticks ?? room, data.length));
|
|
747
|
+
|
|
748
|
+
// Every nth period, rather than a fractional step rounded to the nearest
|
|
749
|
+
// index — rounding lands on the same one twice and skips its neighbour.
|
|
750
|
+
const stride = Math.ceil(data.length / count);
|
|
751
|
+
const picked = [];
|
|
752
|
+
for (let index = 0; index < data.length; index += stride) {
|
|
753
|
+
const datum = data[index];
|
|
754
|
+
if (!datum) continue;
|
|
755
|
+
picked.push({
|
|
756
|
+
key: index,
|
|
757
|
+
text: format ? format(datum, index) : String(datum[xDataKey] ?? '')
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
return picked;
|
|
761
|
+
}, [data, ticks, format, xDataKey, plot.width]);
|
|
762
|
+
|
|
763
|
+
/*
|
|
764
|
+
* Each label is boxed at a readable width and centred on its own candle, not
|
|
765
|
+
* at the width of the slice that candle occupies.
|
|
766
|
+
*
|
|
767
|
+
* A bar chart can tile its labels one band each, because a chart of eight
|
|
768
|
+
* months has bands wide enough to write a month in. Thirty sessions across a
|
|
769
|
+
* phone gives each one about eleven points, and a date boxed at eleven points
|
|
770
|
+
* is a date clipped to nothing — which is what was happening. The axis has
|
|
771
|
+
* already thinned the labels so the ones it kept are at least this far apart,
|
|
772
|
+
* so a box this wide cannot collide with its neighbour; it is clamped inside
|
|
773
|
+
* the plot so the first and last do not hang off the ends.
|
|
774
|
+
*/
|
|
775
|
+
const boxes = useMemo(() => {
|
|
776
|
+
const half = LABEL_BOX / 2;
|
|
777
|
+
return labels.map(label => {
|
|
778
|
+
const centre = bandOf(label.key, data.length, plot);
|
|
779
|
+
const clamped = Math.min(plot.left + plot.width - half, Math.max(plot.left + half, centre));
|
|
780
|
+
return {
|
|
781
|
+
...label,
|
|
782
|
+
left: clamped - half
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
}, [labels, data.length, plot]);
|
|
786
|
+
return /*#__PURE__*/_jsx(View, {
|
|
787
|
+
style: {
|
|
788
|
+
position: 'absolute',
|
|
789
|
+
inset: 0,
|
|
790
|
+
pointerEvents: 'none'
|
|
791
|
+
},
|
|
792
|
+
className: cn(className),
|
|
793
|
+
children: boxes.map(label => /*#__PURE__*/_jsx(Text, {
|
|
794
|
+
size: "xs",
|
|
795
|
+
muted: true,
|
|
796
|
+
numberOfLines: 1,
|
|
797
|
+
style: {
|
|
798
|
+
position: 'absolute',
|
|
799
|
+
bottom: 0,
|
|
800
|
+
left: label.left,
|
|
801
|
+
width: LABEL_BOX,
|
|
802
|
+
textAlign: 'center'
|
|
803
|
+
},
|
|
804
|
+
children: label.text
|
|
805
|
+
}, label.key))
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
CandlestickChartXAxis.displayName = 'CandlestickChart.XAxis';
|
|
809
|
+
CandlestickChartXAxis.layer = 'overlay';
|
|
810
|
+
/** Price labels down the side, aligned to the grid lines. */
|
|
811
|
+
function CandlestickChartYAxis({
|
|
812
|
+
ticks = 4,
|
|
813
|
+
format,
|
|
814
|
+
className
|
|
815
|
+
}) {
|
|
816
|
+
const {
|
|
817
|
+
plot,
|
|
818
|
+
extent
|
|
819
|
+
} = useChart('CandlestickChart.YAxis');
|
|
820
|
+
|
|
821
|
+
/*
|
|
822
|
+
* Read off the settled domain rather than the shared values the paths use.
|
|
823
|
+
* A label is text, and text is JS — following the tween would re-render on
|
|
824
|
+
* every frame of it to redraw a number nobody can read while it moves.
|
|
825
|
+
*/
|
|
826
|
+
const labels = useMemo(() => {
|
|
827
|
+
const [min, max] = extent;
|
|
828
|
+
if (min === max) return [];
|
|
829
|
+
return Array.from({
|
|
830
|
+
length: ticks + 1
|
|
831
|
+
}, (_unused, index) => {
|
|
832
|
+
const value = max - (max - min) * index / ticks;
|
|
833
|
+
return {
|
|
834
|
+
key: index,
|
|
835
|
+
text: format ? format(value) : compactNumber(value)
|
|
836
|
+
};
|
|
837
|
+
});
|
|
838
|
+
}, [extent, ticks, format]);
|
|
839
|
+
return /*#__PURE__*/_jsx(View, {
|
|
840
|
+
pointerEvents: "none",
|
|
841
|
+
style: {
|
|
842
|
+
position: 'absolute',
|
|
843
|
+
left: 0,
|
|
844
|
+
// Each label is centred on the grid line it names: the strip is lifted
|
|
845
|
+
// half a label and grown by a whole one, so `justify-between` lands the
|
|
846
|
+
// text's middle on the line rather than its top edge on the first and
|
|
847
|
+
// its bottom edge on the last.
|
|
848
|
+
top: plot.top - AXIS_LABEL_HEIGHT / 2,
|
|
849
|
+
height: plot.height + AXIS_LABEL_HEIGHT,
|
|
850
|
+
width: Math.max(Y_AXIS_WIDTH - 6, 0),
|
|
851
|
+
justifyContent: 'space-between'
|
|
852
|
+
},
|
|
853
|
+
className: cn(className),
|
|
854
|
+
children: labels.map(label => /*#__PURE__*/_jsx(Text, {
|
|
855
|
+
size: "xs",
|
|
856
|
+
muted: true,
|
|
857
|
+
numberOfLines: 1,
|
|
858
|
+
className: "text-right",
|
|
859
|
+
children: label.text
|
|
860
|
+
}, label.key))
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
CandlestickChartYAxis.displayName = 'CandlestickChart.YAxis';
|
|
864
|
+
CandlestickChartYAxis.layer = 'overlay';
|
|
865
|
+
// Read by the root, which has to leave room for the labels before it lays the
|
|
866
|
+
// plot out — an axis drawn over the plot is unreadable, and makes what it is
|
|
867
|
+
// drawn over unreadable too.
|
|
868
|
+
CandlestickChartYAxis.axis = 'y';
|
|
869
|
+
/**
|
|
870
|
+
* The readout, and the gesture that drives it.
|
|
871
|
+
*
|
|
872
|
+
* There is no crosshair. A candle is already the thing being pointed at, so
|
|
873
|
+
* highlighting it and dimming the rest says the same thing without drawing a
|
|
874
|
+
* line through the chart.
|
|
875
|
+
*
|
|
876
|
+
* The hit area is the whole plot. A readout you have to land on a candle to
|
|
877
|
+
* summon is a readout nobody finds — and candles are thin.
|
|
878
|
+
*/
|
|
879
|
+
function CandlestickChartTooltip({
|
|
880
|
+
formatValue,
|
|
881
|
+
formatX,
|
|
882
|
+
showChange = true,
|
|
883
|
+
className
|
|
884
|
+
}) {
|
|
885
|
+
const {
|
|
886
|
+
data,
|
|
887
|
+
xDataKey,
|
|
888
|
+
openKey,
|
|
889
|
+
highKey,
|
|
890
|
+
lowKey,
|
|
891
|
+
closeKey,
|
|
892
|
+
plot,
|
|
893
|
+
activeIndex,
|
|
894
|
+
activeIndexJS,
|
|
895
|
+
setActiveIndexJS,
|
|
896
|
+
status
|
|
897
|
+
} = useChart('CandlestickChart.Tooltip');
|
|
898
|
+
const total = data.length;
|
|
899
|
+
const left = plot.left;
|
|
900
|
+
const width = plot.width;
|
|
901
|
+
|
|
902
|
+
/*
|
|
903
|
+
* Declared inside the memo, next to its callers: a worklet may only call
|
|
904
|
+
* another worklet, and the rule is enforced by crashing rather than warning.
|
|
905
|
+
*/
|
|
906
|
+
const pan = useMemo(() => {
|
|
907
|
+
const resolve = x => {
|
|
908
|
+
'worklet';
|
|
909
|
+
|
|
910
|
+
if (!total) return;
|
|
911
|
+
const offset = (x - left) / (width || 1);
|
|
912
|
+
// Slices, not points: the finger is inside whichever one it lands on,
|
|
913
|
+
// which is a floor rather than a round to the nearest centre.
|
|
914
|
+
const next = Math.max(0, Math.min(total - 1, Math.floor(offset * total)));
|
|
915
|
+
if (next === activeIndex.value) return;
|
|
916
|
+
activeIndex.value = next;
|
|
917
|
+
runOnJS(setActiveIndexJS)(next);
|
|
918
|
+
};
|
|
919
|
+
return Gesture.Pan().minDistance(0).onBegin(event => {
|
|
920
|
+
'worklet';
|
|
921
|
+
|
|
922
|
+
resolve(event.x);
|
|
923
|
+
}).onUpdate(event => {
|
|
924
|
+
'worklet';
|
|
925
|
+
|
|
926
|
+
resolve(event.x);
|
|
927
|
+
}).onFinalize(() => {
|
|
928
|
+
'worklet';
|
|
929
|
+
|
|
930
|
+
activeIndex.value = -1;
|
|
931
|
+
runOnJS(setActiveIndexJS)(-1);
|
|
932
|
+
});
|
|
933
|
+
}, [total, left, width, activeIndex, setActiveIndexJS]);
|
|
934
|
+
|
|
935
|
+
// The readout centres over its candle and is clamped inside the plot, so it
|
|
936
|
+
// never runs off the edge at the first or last one.
|
|
937
|
+
const labelStyle = useAnimatedStyle(() => {
|
|
938
|
+
const index = activeIndex.value;
|
|
939
|
+
if (index < 0 || !total) return {
|
|
940
|
+
opacity: 0
|
|
941
|
+
};
|
|
942
|
+
const slice = plot.width / total;
|
|
943
|
+
const centre = plot.left + slice * (index + 0.5);
|
|
944
|
+
const half = LABEL_WIDTH / 2;
|
|
945
|
+
const clamped = Math.min(plot.left + plot.width - half, Math.max(plot.left + half, centre));
|
|
946
|
+
return {
|
|
947
|
+
opacity: 1,
|
|
948
|
+
transform: [{
|
|
949
|
+
translateX: clamped - half
|
|
950
|
+
}]
|
|
951
|
+
};
|
|
952
|
+
});
|
|
953
|
+
const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
|
|
954
|
+
const ohlc = ohlcOf(active ?? undefined, openKey, highKey, lowKey, closeKey);
|
|
955
|
+
const fmt = formatValue ?? (value => compactNumber(value));
|
|
956
|
+
const fmtX = formatX ?? (datum => String(datum[xDataKey] ?? ''));
|
|
957
|
+
const rising = useDirectionColor(undefined, 'rising');
|
|
958
|
+
const falling = useDirectionColor(undefined, 'falling');
|
|
959
|
+
if (status === 'loading') return null;
|
|
960
|
+
const change = ohlc ? ohlc.close - ohlc.open : 0;
|
|
961
|
+
const percent = ohlc && ohlc.open !== 0 ? change / ohlc.open * 100 : 0;
|
|
962
|
+
return /*#__PURE__*/_jsx(GestureDetector, {
|
|
963
|
+
gesture: pan,
|
|
964
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
965
|
+
style: StyleSheet.absoluteFill,
|
|
966
|
+
children: /*#__PURE__*/_jsx(Animated.View, {
|
|
967
|
+
pointerEvents: "none",
|
|
968
|
+
style: [{
|
|
969
|
+
position: 'absolute',
|
|
970
|
+
left: 0,
|
|
971
|
+
top: 0,
|
|
972
|
+
width: LABEL_WIDTH
|
|
973
|
+
}, labelStyle],
|
|
974
|
+
children: active && ohlc ? /*#__PURE__*/_jsxs(View, {
|
|
975
|
+
className: cn('gap-0.5 rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg', className),
|
|
976
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
977
|
+
size: "xs",
|
|
978
|
+
muted: true,
|
|
979
|
+
numberOfLines: 1,
|
|
980
|
+
children: fmtX(active)
|
|
981
|
+
}), /*#__PURE__*/_jsxs(View, {
|
|
982
|
+
className: "flex-row gap-3",
|
|
983
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
984
|
+
className: "flex-1 gap-0.5",
|
|
985
|
+
children: [/*#__PURE__*/_jsx(OhlcRow, {
|
|
986
|
+
label: "O",
|
|
987
|
+
value: fmt(ohlc.open, 'open')
|
|
988
|
+
}), /*#__PURE__*/_jsx(OhlcRow, {
|
|
989
|
+
label: "H",
|
|
990
|
+
value: fmt(ohlc.high, 'high')
|
|
991
|
+
})]
|
|
992
|
+
}), /*#__PURE__*/_jsxs(View, {
|
|
993
|
+
className: "flex-1 gap-0.5",
|
|
994
|
+
children: [/*#__PURE__*/_jsx(OhlcRow, {
|
|
995
|
+
label: "L",
|
|
996
|
+
value: fmt(ohlc.low, 'low')
|
|
997
|
+
}), /*#__PURE__*/_jsx(OhlcRow, {
|
|
998
|
+
label: "C",
|
|
999
|
+
value: fmt(ohlc.close, 'close')
|
|
1000
|
+
})]
|
|
1001
|
+
})]
|
|
1002
|
+
}), showChange ? /*#__PURE__*/_jsxs(Text, {
|
|
1003
|
+
size: "xs",
|
|
1004
|
+
weight: "medium",
|
|
1005
|
+
numberOfLines: 1,
|
|
1006
|
+
style: {
|
|
1007
|
+
color: change >= 0 ? rising : falling
|
|
1008
|
+
},
|
|
1009
|
+
children: [change >= 0 ? '+' : '', compactNumber(change), " (", percent >= 0 ? '+' : '', percent.toFixed(2), "%)"]
|
|
1010
|
+
}) : null]
|
|
1011
|
+
}) : null
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
CandlestickChartTooltip.displayName = 'CandlestickChart.Tooltip';
|
|
1017
|
+
CandlestickChartTooltip.layer = 'overlay';
|
|
1018
|
+
|
|
1019
|
+
/** One of the four prices in the readout, named by its initial. */
|
|
1020
|
+
function OhlcRow({
|
|
1021
|
+
label,
|
|
1022
|
+
value
|
|
1023
|
+
}) {
|
|
1024
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
1025
|
+
className: "flex-row items-baseline gap-1",
|
|
1026
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
1027
|
+
size: "xs",
|
|
1028
|
+
muted: true,
|
|
1029
|
+
children: label
|
|
1030
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
1031
|
+
size: "xs",
|
|
1032
|
+
weight: "medium",
|
|
1033
|
+
numberOfLines: 1,
|
|
1034
|
+
children: value
|
|
1035
|
+
})]
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/* -------------------------------------------------------------------------- */
|
|
1040
|
+
/* Header layer */
|
|
1041
|
+
/* -------------------------------------------------------------------------- */
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* The strip above the plot: what the chart is of, what it currently reads, and
|
|
1045
|
+
* what the two colours mean.
|
|
1046
|
+
*
|
|
1047
|
+
* It belongs to the chart rather than to the card around it because it is about
|
|
1048
|
+
* the *plot* — the number changes as a finger moves along the candles. The
|
|
1049
|
+
* card's header is a caption on the tray the chart sits in; this is the chart
|
|
1050
|
+
* introducing itself.
|
|
1051
|
+
*
|
|
1052
|
+
* The value is not derived here. A readout that follows the finger belongs to
|
|
1053
|
+
* whoever owns the data — take it from `onActiveIndexChange` and pass the
|
|
1054
|
+
* formatted string down, so one header can show the latest close when nothing
|
|
1055
|
+
* is pressed and a period's close when something is.
|
|
1056
|
+
*/
|
|
1057
|
+
function CandlestickChartHeader({
|
|
1058
|
+
className,
|
|
1059
|
+
title,
|
|
1060
|
+
value,
|
|
1061
|
+
caption,
|
|
1062
|
+
legend = false,
|
|
1063
|
+
risingLabel = 'Up',
|
|
1064
|
+
fallingLabel = 'Down',
|
|
1065
|
+
children,
|
|
1066
|
+
...props
|
|
1067
|
+
}) {
|
|
1068
|
+
const rising = useDirectionColor(undefined, 'rising');
|
|
1069
|
+
const falling = useDirectionColor(undefined, 'falling');
|
|
1070
|
+
const trailing = children ?? (legend ? /*#__PURE__*/_jsxs(View, {
|
|
1071
|
+
className: "flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1",
|
|
1072
|
+
children: [/*#__PURE__*/_jsx(SeriesSwatch, {
|
|
1073
|
+
color: rising,
|
|
1074
|
+
label: risingLabel
|
|
1075
|
+
}), /*#__PURE__*/_jsx(SeriesSwatch, {
|
|
1076
|
+
color: falling,
|
|
1077
|
+
label: fallingLabel
|
|
1078
|
+
})]
|
|
1079
|
+
}) : null);
|
|
1080
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
1081
|
+
...props,
|
|
1082
|
+
className: cn('flex-row items-start justify-between gap-3 pb-3', className),
|
|
1083
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
1084
|
+
className: "flex-1 gap-0.5",
|
|
1085
|
+
children: [title ? /*#__PURE__*/_jsx(Text, {
|
|
1086
|
+
size: "xs",
|
|
1087
|
+
muted: true,
|
|
1088
|
+
children: title
|
|
1089
|
+
}) : null, value ? /*#__PURE__*/_jsx(Text, {
|
|
1090
|
+
size: "xl",
|
|
1091
|
+
weight: "bold",
|
|
1092
|
+
children: value
|
|
1093
|
+
}) : null, caption ? /*#__PURE__*/_jsx(Text, {
|
|
1094
|
+
size: "xs",
|
|
1095
|
+
muted: true,
|
|
1096
|
+
children: caption
|
|
1097
|
+
}) : null]
|
|
1098
|
+
}), trailing ? /*#__PURE__*/_jsx(View, {
|
|
1099
|
+
className: "shrink pt-1",
|
|
1100
|
+
children: trailing
|
|
1101
|
+
}) : null]
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
CandlestickChartHeader.displayName = 'CandlestickChart.Header';
|
|
1105
|
+
CandlestickChartHeader.layer = 'header';
|
|
1106
|
+
|
|
1107
|
+
/** One colour and what it means. Shared by the legend and the header. */
|
|
1108
|
+
function SeriesSwatch({
|
|
1109
|
+
color,
|
|
1110
|
+
label
|
|
1111
|
+
}) {
|
|
1112
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
1113
|
+
className: "flex-row items-center gap-1.5",
|
|
1114
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
1115
|
+
style: {
|
|
1116
|
+
backgroundColor: color
|
|
1117
|
+
},
|
|
1118
|
+
className: "h-2 w-2 rounded-full"
|
|
1119
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
1120
|
+
size: "xs",
|
|
1121
|
+
muted: true,
|
|
1122
|
+
children: label
|
|
1123
|
+
})]
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
/** The two colours and what they mean, floated over the plot. */
|
|
1127
|
+
function CandlestickChartLegend({
|
|
1128
|
+
className,
|
|
1129
|
+
risingLabel = 'Up',
|
|
1130
|
+
fallingLabel = 'Down',
|
|
1131
|
+
...props
|
|
1132
|
+
}) {
|
|
1133
|
+
const rising = useDirectionColor(undefined, 'rising');
|
|
1134
|
+
const falling = useDirectionColor(undefined, 'falling');
|
|
1135
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
1136
|
+
...props,
|
|
1137
|
+
style: [{
|
|
1138
|
+
pointerEvents: 'none'
|
|
1139
|
+
}, props.style],
|
|
1140
|
+
className: cn('absolute right-2 top-1 flex-row gap-3', className),
|
|
1141
|
+
children: [/*#__PURE__*/_jsx(SeriesSwatch, {
|
|
1142
|
+
color: rising,
|
|
1143
|
+
label: risingLabel
|
|
1144
|
+
}), /*#__PURE__*/_jsx(SeriesSwatch, {
|
|
1145
|
+
color: falling,
|
|
1146
|
+
label: fallingLabel
|
|
1147
|
+
})]
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
CandlestickChartLegend.displayName = 'CandlestickChart.Legend';
|
|
1151
|
+
CandlestickChartLegend.layer = 'overlay';
|
|
1152
|
+
export const CandlestickChart = Object.assign(CandlestickChartRoot, {
|
|
1153
|
+
Header: CandlestickChartHeader,
|
|
1154
|
+
Grid: CandlestickChartGrid,
|
|
1155
|
+
Candles: CandlestickChartCandles,
|
|
1156
|
+
XAxis: CandlestickChartXAxis,
|
|
1157
|
+
YAxis: CandlestickChartYAxis,
|
|
1158
|
+
Tooltip: CandlestickChartTooltip,
|
|
1159
|
+
Legend: CandlestickChartLegend
|
|
1160
|
+
});
|
|
1161
|
+
//# sourceMappingURL=index.js.map
|