panelui-native 0.51.1 → 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.
Files changed (45) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/candlestick-chart/index.js +1161 -0
  3. package/lib/module/components/candlestick-chart/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +73 -7
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/context-menu/index.js +529 -0
  7. package/lib/module/components/context-menu/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +18 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +71 -7
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/sortable/index.js +159 -23
  13. package/lib/module/components/sortable/index.js.map +1 -1
  14. package/lib/module/components/tabs/index.js +56 -15
  15. package/lib/module/components/tabs/index.js.map +1 -1
  16. package/lib/module/components/time-picker/index.js +295 -31
  17. package/lib/module/components/time-picker/index.js.map +1 -1
  18. package/lib/module/index.js +3 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
  21. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  23. package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
  24. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/menu/index.d.ts +20 -20
  26. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/popover/index.d.ts +51 -1
  28. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/sortable/index.d.ts +13 -3
  30. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/tabs/index.d.ts +34 -2
  32. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +3 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/candlestick-chart/index.tsx +1360 -0
  38. package/src/components/combobox/index.tsx +84 -6
  39. package/src/components/context-menu/index.tsx +658 -0
  40. package/src/components/menu/index.tsx +17 -10
  41. package/src/components/popover/index.tsx +94 -6
  42. package/src/components/sortable/index.tsx +208 -22
  43. package/src/components/tabs/index.tsx +82 -16
  44. package/src/components/time-picker/index.tsx +330 -35
  45. package/src/index.ts +27 -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
+ });