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