react-native-livechart 3.9.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AreaDotsOverlay.d.ts +24 -0
- package/dist/components/AreaDotsOverlay.d.ts.map +1 -0
- package/dist/components/AxisLabelOverlay.d.ts +19 -3
- package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
- package/dist/components/CrosshairOverlay.d.ts +5 -1
- package/dist/components/CrosshairOverlay.d.ts.map +1 -1
- package/dist/components/CustomTooltipOverlay.d.ts +8 -2
- package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
- package/dist/components/ExtremaConnectorOverlay.d.ts +32 -0
- package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -0
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +11 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +45 -2
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +18 -2
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +4 -0
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/hooks/crosshairShared.d.ts +12 -0
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/theme.d.ts +7 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/types.d.ts +103 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/AreaDotsOverlay.tsx +62 -0
- package/src/components/AxisLabelOverlay.tsx +399 -46
- package/src/components/CrosshairOverlay.tsx +15 -5
- package/src/components/CustomTooltipOverlay.tsx +30 -5
- package/src/components/ExtremaConnectorOverlay.tsx +193 -0
- package/src/components/LiveChart.tsx +78 -8
- package/src/components/LiveChartSeries.tsx +18 -0
- package/src/core/liveChartEngineTick.ts +51 -6
- package/src/core/liveChartSeriesEngineTick.ts +34 -0
- package/src/core/resolveConfig.ts +100 -2
- package/src/core/useLiveChartEngine.ts +42 -2
- package/src/core/useLiveChartSeriesEngine.ts +26 -0
- package/src/hooks/crosshairShared.ts +20 -1
- package/src/hooks/useCrosshair.ts +6 -0
- package/src/index.ts +1 -0
- package/src/theme.ts +14 -0
- package/src/types.ts +105 -8
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { DashPathEffect, Path } from "@shopify/react-native-skia";
|
|
2
|
+
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ResolvedAxisLabelConfig,
|
|
6
|
+
ResolvedLineStyleConfig,
|
|
7
|
+
} from "../core/resolveConfig";
|
|
8
|
+
import type {
|
|
9
|
+
ChartEngineExtrema,
|
|
10
|
+
ChartEngineLayout,
|
|
11
|
+
} from "../core/useLiveChartEngine";
|
|
12
|
+
import type { ChartPadding } from "../draw/line";
|
|
13
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
14
|
+
import { EXTREMA_EDGE_INSET, EXTREMA_LABEL_FONT_SIZE } from "./AxisLabelOverlay";
|
|
15
|
+
|
|
16
|
+
/** How far off-plot (px) the extremum may sit before the connector is dropped. */
|
|
17
|
+
const CONNECTOR_CULL = 24;
|
|
18
|
+
/** Gap (px) between the connector's end and the value label. */
|
|
19
|
+
const LABEL_GAP = 3;
|
|
20
|
+
/** Single-line text height as a fraction of font size — a safe over-estimate, so
|
|
21
|
+
* the connector stops just shy of the label rather than running under it. */
|
|
22
|
+
const LABEL_HEIGHT_FACTOR = 1.5;
|
|
23
|
+
|
|
24
|
+
/** Connector style + the label font size (to estimate where the label edge is). */
|
|
25
|
+
export interface ResolvedConnector {
|
|
26
|
+
line: ResolvedLineStyleConfig;
|
|
27
|
+
fontSize: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The connector config for one axis-label side — non-null only when it's an
|
|
32
|
+
* `"extrema-edge"` label with its connector enabled. The line `color` defaults
|
|
33
|
+
* to the label color (then `defaultColor`), so the connector matches its readout.
|
|
34
|
+
*/
|
|
35
|
+
export function labelConnector(
|
|
36
|
+
cfg: ResolvedAxisLabelConfig | null,
|
|
37
|
+
defaultColor: string,
|
|
38
|
+
): ResolvedConnector | null {
|
|
39
|
+
if (!cfg || cfg.position !== "extrema-edge" || !cfg.connector) return null;
|
|
40
|
+
return {
|
|
41
|
+
line: {
|
|
42
|
+
...cfg.connector,
|
|
43
|
+
color: cfg.connector.color ?? cfg.color ?? defaultColor,
|
|
44
|
+
},
|
|
45
|
+
fontSize: cfg.fontSize ?? EXTREMA_LABEL_FONT_SIZE,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A single vertical connector from the edge value label down/up to its dot. */
|
|
50
|
+
function ConnectorLine({
|
|
51
|
+
side,
|
|
52
|
+
timeSV,
|
|
53
|
+
valueSV,
|
|
54
|
+
timeOffset,
|
|
55
|
+
engine,
|
|
56
|
+
padding,
|
|
57
|
+
config,
|
|
58
|
+
}: {
|
|
59
|
+
side: "top" | "bottom";
|
|
60
|
+
timeSV: SharedValue<number>;
|
|
61
|
+
valueSV: SharedValue<number>;
|
|
62
|
+
timeOffset: number;
|
|
63
|
+
engine: ChartEngineLayout;
|
|
64
|
+
padding: ChartPadding;
|
|
65
|
+
config: ResolvedConnector;
|
|
66
|
+
}) {
|
|
67
|
+
const builder = usePathBuilder();
|
|
68
|
+
const { line, fontSize } = config;
|
|
69
|
+
|
|
70
|
+
const path = useDerivedValue(() => {
|
|
71
|
+
const b = builder.value;
|
|
72
|
+
const value = valueSV.get();
|
|
73
|
+
const time = timeSV.get();
|
|
74
|
+
const cw = engine.canvasWidth.get();
|
|
75
|
+
const ch = engine.canvasHeight.get();
|
|
76
|
+
const displayMin = engine.displayMin.get();
|
|
77
|
+
const displayMax = engine.displayMax.get();
|
|
78
|
+
const win = engine.displayWindow.get();
|
|
79
|
+
const ts = engine.timestamp.get();
|
|
80
|
+
|
|
81
|
+
const chartLeft = padding.left;
|
|
82
|
+
const chartRight = cw - padding.right;
|
|
83
|
+
const chartW = chartRight - chartLeft;
|
|
84
|
+
const chartTop = padding.top;
|
|
85
|
+
const chartBottom = ch - padding.bottom;
|
|
86
|
+
const chartH = chartBottom - chartTop;
|
|
87
|
+
const valRange = displayMax - displayMin;
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
!Number.isFinite(value) ||
|
|
91
|
+
cw === 0 ||
|
|
92
|
+
ch === 0 ||
|
|
93
|
+
chartW <= 0 ||
|
|
94
|
+
chartH <= 0 ||
|
|
95
|
+
win <= 0 ||
|
|
96
|
+
valRange <= 0
|
|
97
|
+
) {
|
|
98
|
+
return b.detach();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const winStart = ts - win;
|
|
102
|
+
const px = chartLeft + ((time + timeOffset - winStart) / win) * chartW;
|
|
103
|
+
// Off-plot (scrolled out) — emit nothing this frame.
|
|
104
|
+
if (px < chartLeft - CONNECTOR_CULL || px > chartRight + CONNECTOR_CULL) {
|
|
105
|
+
return b.detach();
|
|
106
|
+
}
|
|
107
|
+
const py = chartTop + ((displayMax - value) / valRange) * chartH;
|
|
108
|
+
|
|
109
|
+
// Stop the line just shy of the value label (which sits on the rail), not at
|
|
110
|
+
// the plot edge — so it never runs through the text. The label's inner edge
|
|
111
|
+
// is estimated from its font height (the label measures itself in RN; an
|
|
112
|
+
// over-estimate keeps a clean gap). Skip when the dot is inside the band.
|
|
113
|
+
const band = fontSize * LABEL_HEIGHT_FACTOR;
|
|
114
|
+
if (side === "top") {
|
|
115
|
+
const edgeY = EXTREMA_EDGE_INSET + band + LABEL_GAP;
|
|
116
|
+
if (py <= edgeY) return b.detach();
|
|
117
|
+
b.moveTo(px, edgeY);
|
|
118
|
+
b.lineTo(px, py);
|
|
119
|
+
} else {
|
|
120
|
+
const edgeY = chartBottom - band - LABEL_GAP;
|
|
121
|
+
if (py >= edgeY) return b.detach();
|
|
122
|
+
b.moveTo(px, py);
|
|
123
|
+
b.lineTo(px, edgeY);
|
|
124
|
+
}
|
|
125
|
+
return b.detach();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Path
|
|
130
|
+
path={path}
|
|
131
|
+
style="stroke"
|
|
132
|
+
strokeWidth={line.strokeWidth}
|
|
133
|
+
color={line.color}
|
|
134
|
+
>
|
|
135
|
+
{line.intervals ? <DashPathEffect intervals={line.intervals} /> : null}
|
|
136
|
+
</Path>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Skia connector lines for `"extrema-edge"` axis labels — a vertical line at the
|
|
142
|
+
* extremum's x joining its edge value label to the marker dot on the data point.
|
|
143
|
+
* Drawn in the canvas (so it dashes "like all lines"); the dot + label are RN
|
|
144
|
+
* overlays positioned with the same projection, so the three pieces line up. The
|
|
145
|
+
* line stops at the label edge, not the plot edge, so it never crosses the text.
|
|
146
|
+
*
|
|
147
|
+
* `top` / `bottom` are non-null only for a side in `"extrema-edge"` mode with its
|
|
148
|
+
* connector enabled.
|
|
149
|
+
*/
|
|
150
|
+
export function ExtremaConnectorOverlay({
|
|
151
|
+
engine,
|
|
152
|
+
padding,
|
|
153
|
+
extremaTimeOffset = 0,
|
|
154
|
+
top,
|
|
155
|
+
bottom,
|
|
156
|
+
}: {
|
|
157
|
+
engine: ChartEngineLayout & Partial<ChartEngineExtrema>;
|
|
158
|
+
padding: ChartPadding;
|
|
159
|
+
extremaTimeOffset?: number;
|
|
160
|
+
top: ResolvedConnector | null;
|
|
161
|
+
bottom: ResolvedConnector | null;
|
|
162
|
+
}) {
|
|
163
|
+
const hasTop = top != null && engine.extremaMaxTime != null;
|
|
164
|
+
const hasBottom = bottom != null && engine.extremaMinTime != null;
|
|
165
|
+
if (!hasTop && !hasBottom) return null;
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
{hasTop && (
|
|
170
|
+
<ConnectorLine
|
|
171
|
+
side="top"
|
|
172
|
+
timeSV={engine.extremaMaxTime!}
|
|
173
|
+
valueSV={engine.extremaMaxValue!}
|
|
174
|
+
timeOffset={extremaTimeOffset}
|
|
175
|
+
engine={engine}
|
|
176
|
+
padding={padding}
|
|
177
|
+
config={top!}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
{hasBottom && (
|
|
181
|
+
<ConnectorLine
|
|
182
|
+
side="bottom"
|
|
183
|
+
timeSV={engine.extremaMinTime!}
|
|
184
|
+
valueSV={engine.extremaMinValue!}
|
|
185
|
+
timeOffset={extremaTimeOffset}
|
|
186
|
+
engine={engine}
|
|
187
|
+
padding={padding}
|
|
188
|
+
config={bottom!}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
|
|
20
20
|
import { DEFAULT_ACCENT_COLOR } from "../constants";
|
|
21
21
|
import {
|
|
22
|
+
resolveAreaDots,
|
|
22
23
|
resolveAxisLabel,
|
|
23
24
|
resolveBadge,
|
|
24
25
|
resolveDegen,
|
|
@@ -73,6 +74,7 @@ import {
|
|
|
73
74
|
applyPaletteOverride,
|
|
74
75
|
leftEdgeFadeColorsFromBgRgb,
|
|
75
76
|
parseColorRgb,
|
|
77
|
+
parseColorRgba,
|
|
76
78
|
resolveTheme,
|
|
77
79
|
} from "../theme";
|
|
78
80
|
import type {
|
|
@@ -85,7 +87,12 @@ import {
|
|
|
85
87
|
ThresholdBadgeOverlay,
|
|
86
88
|
ThresholdLineOverlay,
|
|
87
89
|
} from "./ThresholdLineOverlay";
|
|
90
|
+
import { AreaDotsOverlay } from "./AreaDotsOverlay";
|
|
88
91
|
import { AxisLabelOverlay } from "./AxisLabelOverlay";
|
|
92
|
+
import {
|
|
93
|
+
ExtremaConnectorOverlay,
|
|
94
|
+
labelConnector,
|
|
95
|
+
} from "./ExtremaConnectorOverlay";
|
|
89
96
|
import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
|
|
90
97
|
import { CustomTooltipOverlay } from "./CustomTooltipOverlay";
|
|
91
98
|
import { BadgeOverlay } from "./BadgeOverlay";
|
|
@@ -146,6 +153,7 @@ function useLiveChartController({
|
|
|
146
153
|
theme = "dark",
|
|
147
154
|
accentColor = DEFAULT_ACCENT_COLOR,
|
|
148
155
|
gradient = true,
|
|
156
|
+
areaDots,
|
|
149
157
|
line: lineProp,
|
|
150
158
|
font: fontProp,
|
|
151
159
|
insets,
|
|
@@ -232,6 +240,9 @@ function useLiveChartController({
|
|
|
232
240
|
// Static charts run no gestures, so scrub-action (tap/drag to lock a price) is off.
|
|
233
241
|
const scrubActionCfg = isStatic ? null : resolveScrubAction(scrubAction);
|
|
234
242
|
const gradientCfg = isCandle ? null : resolveGradient(gradient);
|
|
243
|
+
// Dot-lattice area fill (clipped to the under-line region). Inert in candle
|
|
244
|
+
// mode, same as the gradient fill.
|
|
245
|
+
const areaDotsCfg = isCandle ? null : resolveAreaDots(areaDots);
|
|
235
246
|
// Threshold split is a line-mode feature (candle bodies carry their own up/down
|
|
236
247
|
// colors), so it's inert in candle mode — same as the area gradient.
|
|
237
248
|
const thresholdCfg = isCandle ? null : resolveThreshold(threshold);
|
|
@@ -405,6 +416,22 @@ function useLiveChartController({
|
|
|
405
416
|
// Straight polyline instead of the monotone cubic when line.curve === "linear".
|
|
406
417
|
lineProp?.curve === "linear",
|
|
407
418
|
);
|
|
419
|
+
|
|
420
|
+
// Area-dots fill shader color as a vec4 (channels 0..1), with the config
|
|
421
|
+
// `opacity` folded into the alpha. Defaults to a faint tint of the line/accent
|
|
422
|
+
// color (theme-aware) so out-of-the-box dots read as a subtle field.
|
|
423
|
+
const areaDotRgb = parseColorRgb(lineProp?.color ?? palette.line);
|
|
424
|
+
const [adR, adG, adB, adA] = parseColorRgba(
|
|
425
|
+
areaDotsCfg?.color ??
|
|
426
|
+
`rgba(${areaDotRgb[0]}, ${areaDotRgb[1]}, ${areaDotRgb[2]}, 0.22)`,
|
|
427
|
+
);
|
|
428
|
+
const areaDotColorVec = [
|
|
429
|
+
adR / 255,
|
|
430
|
+
adG / 255,
|
|
431
|
+
adB / 255,
|
|
432
|
+
adA * (areaDotsCfg?.opacity ?? 1),
|
|
433
|
+
];
|
|
434
|
+
|
|
408
435
|
const { upBodiesPath, downBodiesPath, upWicksPath, downWicksPath } =
|
|
409
436
|
useCandlePaths(
|
|
410
437
|
engine,
|
|
@@ -625,6 +652,9 @@ function useLiveChartController({
|
|
|
625
652
|
lineProp,
|
|
626
653
|
formatValue,
|
|
627
654
|
isCandle,
|
|
655
|
+
// Half a candle width (seconds) so an "extrema" axis label's dot lands on the
|
|
656
|
+
// candle's drawn center, not its bucket-start (left) edge. 0 in line mode.
|
|
657
|
+
extremaTimeOffset: isCandle ? candleWidth / 2 : 0,
|
|
628
658
|
// configs
|
|
629
659
|
yAxisCfg,
|
|
630
660
|
xAxisCfg,
|
|
@@ -632,6 +662,8 @@ function useLiveChartController({
|
|
|
632
662
|
scrubCfg,
|
|
633
663
|
scrubActionCfg,
|
|
634
664
|
gradientCfg,
|
|
665
|
+
areaDotsCfg,
|
|
666
|
+
areaDotColorVec,
|
|
635
667
|
valueLineCfg,
|
|
636
668
|
pulseCfg,
|
|
637
669
|
dotCfg,
|
|
@@ -700,6 +732,9 @@ function useLiveChartController({
|
|
|
700
732
|
// RN axis edge labels (floated over the canvas as a sibling layer)
|
|
701
733
|
topLabelCfg,
|
|
702
734
|
bottomLabelCfg,
|
|
735
|
+
// Skia connector lines for "extrema-edge" labels (dot → edge readout).
|
|
736
|
+
topConnector: labelConnector(topLabelCfg, palette.gridLabel),
|
|
737
|
+
bottomConnector: labelConnector(bottomLabelCfg, palette.gridLabel),
|
|
703
738
|
};
|
|
704
739
|
}
|
|
705
740
|
|
|
@@ -726,6 +761,8 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
|
|
|
726
761
|
gridStyleCfg,
|
|
727
762
|
metricsCfg,
|
|
728
763
|
gradientCfg,
|
|
764
|
+
areaDotsCfg,
|
|
765
|
+
areaDotColorVec,
|
|
729
766
|
fillPath,
|
|
730
767
|
gradientEnd,
|
|
731
768
|
gradientColors,
|
|
@@ -755,6 +792,20 @@ function ChartFillLayer({ model }: { model: LiveChartModel }) {
|
|
|
755
792
|
</Group>
|
|
756
793
|
)}
|
|
757
794
|
|
|
795
|
+
{/* Dot-lattice area fill (the under-line `fillPath` painted with a dot
|
|
796
|
+
shader). Drawn before the gradient so a gradient (if also enabled)
|
|
797
|
+
composites on top. */}
|
|
798
|
+
{areaDotsCfg && (
|
|
799
|
+
<Group opacity={reveal.fillOpacity}>
|
|
800
|
+
<AreaDotsOverlay
|
|
801
|
+
fillPath={fillPath}
|
|
802
|
+
color={areaDotColorVec}
|
|
803
|
+
spacing={areaDotsCfg.spacing}
|
|
804
|
+
size={areaDotsCfg.size}
|
|
805
|
+
/>
|
|
806
|
+
</Group>
|
|
807
|
+
)}
|
|
808
|
+
|
|
758
809
|
{/* Area gradient fill */}
|
|
759
810
|
{gradientCfg && (
|
|
760
811
|
<Group opacity={reveal.fillOpacity}>
|
|
@@ -1072,10 +1123,10 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
|
|
|
1072
1123
|
renderTooltip,
|
|
1073
1124
|
} = model;
|
|
1074
1125
|
|
|
1075
|
-
// A custom
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
const customTooltipActive =
|
|
1126
|
+
// A custom tooltip is an RN overlay (sibling of <Canvas>), so the built-in
|
|
1127
|
+
// Skia tooltip is suppressed here while it's active — the line pill in line
|
|
1128
|
+
// mode, and the OHLC stack in candle mode (see the stack gate below).
|
|
1129
|
+
const customTooltipActive = renderTooltip != null;
|
|
1079
1130
|
|
|
1080
1131
|
if (!tradeStreamResolved && !scrubCfg) return null;
|
|
1081
1132
|
|
|
@@ -1110,6 +1161,7 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
|
|
|
1110
1161
|
palette={palette}
|
|
1111
1162
|
font={skiaFont}
|
|
1112
1163
|
showTooltip={scrubCfg.tooltip && !customTooltipActive}
|
|
1164
|
+
lineTop={crosshair.tooltipLineTop}
|
|
1113
1165
|
selectionDot={selectionDot}
|
|
1114
1166
|
selectionY={crosshair.scrubDotY}
|
|
1115
1167
|
scrubActive={crosshair.scrubActive}
|
|
@@ -1128,8 +1180,9 @@ function ChartScrubLayer({ model }: { model: LiveChartModel }) {
|
|
|
1128
1180
|
>
|
|
1129
1181
|
{/* Candle charts render a multi-line OHLC tooltip; the line
|
|
1130
1182
|
chart falls back to CrosshairOverlay's default value/time
|
|
1131
|
-
body. Composed as children rather than a JSX-valued prop.
|
|
1132
|
-
|
|
1183
|
+
body. Composed as children rather than a JSX-valued prop.
|
|
1184
|
+
Suppressed when a custom `renderTooltip` owns the readout. */}
|
|
1185
|
+
{isCandle && !customTooltipActive ? (
|
|
1133
1186
|
<MultiSeriesTooltipStack
|
|
1134
1187
|
tooltipLayout={crosshair.tooltipLayout}
|
|
1135
1188
|
font={skiaFont}
|
|
@@ -1282,6 +1335,9 @@ export function LiveChart(props: LiveChartProps) {
|
|
|
1282
1335
|
scrubCfg,
|
|
1283
1336
|
crosshair,
|
|
1284
1337
|
isCandle,
|
|
1338
|
+
extremaTimeOffset,
|
|
1339
|
+
topConnector,
|
|
1340
|
+
bottomConnector,
|
|
1285
1341
|
} = model;
|
|
1286
1342
|
|
|
1287
1343
|
return (
|
|
@@ -1312,6 +1368,16 @@ export function LiveChart(props: LiveChartProps) {
|
|
|
1312
1368
|
{/* Line stack above the fade so the line stays crisp at the left edge. */}
|
|
1313
1369
|
<ChartStack model={model} />
|
|
1314
1370
|
|
|
1371
|
+
{/* "extrema-edge" connector lines (dot → edge readout), above the chart
|
|
1372
|
+
content so the dashed guide reads over the line / candles. */}
|
|
1373
|
+
<ExtremaConnectorOverlay
|
|
1374
|
+
engine={engine}
|
|
1375
|
+
padding={effectivePadding}
|
|
1376
|
+
extremaTimeOffset={extremaTimeOffset}
|
|
1377
|
+
top={topConnector}
|
|
1378
|
+
bottom={bottomConnector}
|
|
1379
|
+
/>
|
|
1380
|
+
|
|
1315
1381
|
{/* Reference-line badges + labels above the fade so they stay crisp. */}
|
|
1316
1382
|
<ChartRefBadgeLayer model={model} />
|
|
1317
1383
|
|
|
@@ -1336,6 +1402,7 @@ export function LiveChart(props: LiveChartProps) {
|
|
|
1336
1402
|
formatValue={formatValue}
|
|
1337
1403
|
defaultColor={palette.gridLabel}
|
|
1338
1404
|
padding={effectivePadding}
|
|
1405
|
+
extremaTimeOffset={extremaTimeOffset}
|
|
1339
1406
|
/>
|
|
1340
1407
|
|
|
1341
1408
|
{/* Custom-rendered markers — RN views floated over the canvas (non-Skia),
|
|
@@ -1351,20 +1418,23 @@ export function LiveChart(props: LiveChartProps) {
|
|
|
1351
1418
|
)}
|
|
1352
1419
|
|
|
1353
1420
|
{/* Custom scrub tooltip — an RN view floated over the canvas (non-Skia),
|
|
1354
|
-
positioned on the UI thread. Sibling of <Canvas>.
|
|
1355
|
-
|
|
1421
|
+
positioned on the UI thread. Sibling of <Canvas>. Works in line and
|
|
1422
|
+
candle mode (candle exposes the OHLC via `scrubCandle`). */}
|
|
1423
|
+
{scrubCfg && renderTooltip && (
|
|
1356
1424
|
<CustomTooltipOverlay
|
|
1357
1425
|
renderTooltip={renderTooltip}
|
|
1358
1426
|
scrubX={crosshair.scrubX}
|
|
1359
1427
|
scrubValue={crosshair.scrubValue}
|
|
1360
1428
|
scrubTime={crosshair.scrubTime}
|
|
1361
1429
|
scrubActive={crosshair.scrubActive}
|
|
1430
|
+
scrubCandle={crosshair.scrubCandle}
|
|
1362
1431
|
crosshairOpacity={crosshair.crosshairOpacity}
|
|
1363
1432
|
tooltipLayout={crosshair.tooltipLayout}
|
|
1364
1433
|
engine={engine}
|
|
1365
1434
|
padding={effectivePadding}
|
|
1366
1435
|
placement={scrubCfg.tooltipPlacement}
|
|
1367
1436
|
margin={scrubCfg.tooltipMargin}
|
|
1437
|
+
lineTop={crosshair.tooltipLineTop}
|
|
1368
1438
|
/>
|
|
1369
1439
|
)}
|
|
1370
1440
|
</View>
|
|
@@ -63,6 +63,10 @@ import {
|
|
|
63
63
|
} from "../theme";
|
|
64
64
|
import type { LiveChartSeriesProps, Marker, SeriesConfig } from "../types";
|
|
65
65
|
import { AxisLabelOverlay } from "./AxisLabelOverlay";
|
|
66
|
+
import {
|
|
67
|
+
ExtremaConnectorOverlay,
|
|
68
|
+
labelConnector,
|
|
69
|
+
} from "./ExtremaConnectorOverlay";
|
|
66
70
|
import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
|
|
67
71
|
import { CrosshairLine } from "./CrosshairLine";
|
|
68
72
|
import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
|
|
@@ -395,6 +399,9 @@ function useLiveChartSeriesController({
|
|
|
395
399
|
// RN axis edge labels (floated over the canvas as a sibling layer)
|
|
396
400
|
topLabelCfg,
|
|
397
401
|
bottomLabelCfg,
|
|
402
|
+
// Skia connector lines for "extrema-edge" labels (dot → edge readout).
|
|
403
|
+
topConnector: labelConnector(topLabelCfg, palette.gridLabel),
|
|
404
|
+
bottomConnector: labelConnector(bottomLabelCfg, palette.gridLabel),
|
|
398
405
|
};
|
|
399
406
|
}
|
|
400
407
|
|
|
@@ -654,6 +661,8 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
|
|
|
654
661
|
formatValue,
|
|
655
662
|
topLabelCfg,
|
|
656
663
|
bottomLabelCfg,
|
|
664
|
+
topConnector,
|
|
665
|
+
bottomConnector,
|
|
657
666
|
markersActive,
|
|
658
667
|
markersSV,
|
|
659
668
|
renderMarker,
|
|
@@ -696,6 +705,15 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
|
|
|
696
705
|
<Canvas style={{ flex: 1, minHeight: layoutHeight || 1 }}>
|
|
697
706
|
<SeriesChartStack model={model} />
|
|
698
707
|
|
|
708
|
+
{/* "extrema-edge" connector lines (dot → edge readout). Outside the
|
|
709
|
+
stack's degen-shake group so they track the (unshaken) RN dot. */}
|
|
710
|
+
<ExtremaConnectorOverlay
|
|
711
|
+
engine={engine}
|
|
712
|
+
padding={effectivePadding}
|
|
713
|
+
top={topConnector}
|
|
714
|
+
bottom={bottomConnector}
|
|
715
|
+
/>
|
|
716
|
+
|
|
699
717
|
{leftEdgeFadeCfg && (
|
|
700
718
|
<LeftEdgeFade
|
|
701
719
|
paddingLeft={effectivePadding.left}
|
|
@@ -9,6 +9,17 @@ export interface EngineTickMutable {
|
|
|
9
9
|
displayMax: number;
|
|
10
10
|
displayWindow: number;
|
|
11
11
|
timestamp: number;
|
|
12
|
+
/**
|
|
13
|
+
* Value + time of the lowest / highest data point in the visible window —
|
|
14
|
+
* the actual extrema, NOT the smoothed display bounds (which carry margin and
|
|
15
|
+
* fold in the live value / reference lines). `NaN` when the window holds no
|
|
16
|
+
* data. Used to float `topLabel` / `bottomLabel` at their occurrence point
|
|
17
|
+
* (see {@link AxisLabelConfig.position} `"extrema"`).
|
|
18
|
+
*/
|
|
19
|
+
extremaMinValue: number;
|
|
20
|
+
extremaMaxValue: number;
|
|
21
|
+
extremaMinTime: number;
|
|
22
|
+
extremaMaxTime: number;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
export interface EngineTickInput {
|
|
@@ -93,6 +104,12 @@ export function tickLiveChartEngineFrame(
|
|
|
93
104
|
|
|
94
105
|
let tMin = Infinity;
|
|
95
106
|
let tMax = -Infinity;
|
|
107
|
+
// Time of the running min / max — captured alongside the value so an extrema
|
|
108
|
+
// label can be pinned at the point's x. Holds the data extrema only (folded
|
|
109
|
+
// below with the live value / references for the Y range, but snapshotted
|
|
110
|
+
// before that into `extrema*` so the label tracks the real high/low).
|
|
111
|
+
let minTime = 0;
|
|
112
|
+
let maxTime = 0;
|
|
96
113
|
|
|
97
114
|
if (input.mode === "candle") {
|
|
98
115
|
const candles = input.candles;
|
|
@@ -107,17 +124,29 @@ export function tickLiveChartEngineFrame(
|
|
|
107
124
|
for (let i = lo; i < candles.length; i++) {
|
|
108
125
|
if (candles[i].time > state.timestamp) break;
|
|
109
126
|
/* istanbul ignore next -- trivial min/max */
|
|
110
|
-
if (candles[i].low < tMin)
|
|
127
|
+
if (candles[i].low < tMin) {
|
|
128
|
+
tMin = candles[i].low;
|
|
129
|
+
minTime = candles[i].time;
|
|
130
|
+
}
|
|
111
131
|
/* istanbul ignore next -- trivial min/max */
|
|
112
|
-
if (candles[i].high > tMax)
|
|
132
|
+
if (candles[i].high > tMax) {
|
|
133
|
+
tMax = candles[i].high;
|
|
134
|
+
maxTime = candles[i].time;
|
|
135
|
+
}
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
138
|
const lc = input.liveCandle;
|
|
116
139
|
if (lc) {
|
|
117
140
|
/* istanbul ignore next -- trivial min/max */
|
|
118
|
-
if (lc.low < tMin)
|
|
141
|
+
if (lc.low < tMin) {
|
|
142
|
+
tMin = lc.low;
|
|
143
|
+
minTime = lc.time;
|
|
144
|
+
}
|
|
119
145
|
/* istanbul ignore next -- trivial min/max */
|
|
120
|
-
if (lc.high > tMax)
|
|
146
|
+
if (lc.high > tMax) {
|
|
147
|
+
tMax = lc.high;
|
|
148
|
+
maxTime = lc.time;
|
|
149
|
+
}
|
|
121
150
|
}
|
|
122
151
|
} else {
|
|
123
152
|
const points = input.points;
|
|
@@ -130,11 +159,27 @@ export function tickLiveChartEngineFrame(
|
|
|
130
159
|
}
|
|
131
160
|
for (let i = lo; i < points.length; i++) {
|
|
132
161
|
const v = points[i].value;
|
|
133
|
-
if (v < tMin)
|
|
134
|
-
|
|
162
|
+
if (v < tMin) {
|
|
163
|
+
tMin = v;
|
|
164
|
+
minTime = points[i].time;
|
|
165
|
+
}
|
|
166
|
+
if (v > tMax) {
|
|
167
|
+
tMax = v;
|
|
168
|
+
maxTime = points[i].time;
|
|
169
|
+
}
|
|
135
170
|
}
|
|
136
171
|
}
|
|
137
172
|
|
|
173
|
+
// Snapshot the raw data extrema (value + time) before the live value /
|
|
174
|
+
// reference values fold into tMin/tMax for the Y range. NaN when the window
|
|
175
|
+
// is empty so the extrema label hides rather than pinning to a fake point.
|
|
176
|
+
const hasMin = tMin !== Infinity;
|
|
177
|
+
const hasMax = tMax !== -Infinity;
|
|
178
|
+
state.extremaMinValue = hasMin ? tMin : NaN;
|
|
179
|
+
state.extremaMaxValue = hasMax ? tMax : NaN;
|
|
180
|
+
state.extremaMinTime = hasMin ? minTime : NaN;
|
|
181
|
+
state.extremaMaxTime = hasMax ? maxTime : NaN;
|
|
182
|
+
|
|
138
183
|
const cv = state.displayValue;
|
|
139
184
|
if (cv < tMin) tMin = cv;
|
|
140
185
|
if (cv > tMax) tMax = cv;
|
|
@@ -9,6 +9,16 @@ export interface MultiEngineTickMutable {
|
|
|
9
9
|
timestamp: number;
|
|
10
10
|
displayValues: number[];
|
|
11
11
|
opacities: number[];
|
|
12
|
+
/**
|
|
13
|
+
* Value + time of the lowest / highest point across the visible series — the
|
|
14
|
+
* actual extrema, NOT the smoothed display bounds. `NaN` when no visible
|
|
15
|
+
* series has data in the window. Used to float `topLabel` / `bottomLabel` at
|
|
16
|
+
* their occurrence point (see {@link AxisLabelConfig.position} `"extrema"`).
|
|
17
|
+
*/
|
|
18
|
+
extremaMinValue: number;
|
|
19
|
+
extremaMaxValue: number;
|
|
20
|
+
extremaMinTime: number;
|
|
21
|
+
extremaMaxTime: number;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export interface MultiEngineTickInput {
|
|
@@ -95,6 +105,13 @@ export function tickLiveChartSeriesEngineFrame(
|
|
|
95
105
|
|
|
96
106
|
let tMin = Infinity;
|
|
97
107
|
let tMax = -Infinity;
|
|
108
|
+
// Time of the running global min / max data point — captured so an extrema
|
|
109
|
+
// label can be pinned at its x (data extrema only; the live tips folded below
|
|
110
|
+
// into the Y range are snapshotted out before that).
|
|
111
|
+
let minTime = 0;
|
|
112
|
+
let maxTime = 0;
|
|
113
|
+
let dataMin = Infinity;
|
|
114
|
+
let dataMax = -Infinity;
|
|
98
115
|
|
|
99
116
|
for (let i = 0; i < n; i++) {
|
|
100
117
|
if (series[i].visible === false) continue;
|
|
@@ -112,12 +129,29 @@ export function tickLiveChartSeriesEngineFrame(
|
|
|
112
129
|
if (v < tMin) tMin = v;
|
|
113
130
|
/* istanbul ignore next -- trivial min/max */
|
|
114
131
|
if (v > tMax) tMax = v;
|
|
132
|
+
if (v < dataMin) {
|
|
133
|
+
dataMin = v;
|
|
134
|
+
minTime = points[j].time;
|
|
135
|
+
}
|
|
136
|
+
if (v > dataMax) {
|
|
137
|
+
dataMax = v;
|
|
138
|
+
maxTime = points[j].time;
|
|
139
|
+
}
|
|
115
140
|
}
|
|
116
141
|
const cv = state.displayValues[i];
|
|
117
142
|
if (cv < tMin) tMin = cv;
|
|
118
143
|
if (cv > tMax) tMax = cv;
|
|
119
144
|
}
|
|
120
145
|
|
|
146
|
+
// Snapshot the raw data extrema before the references fold in. NaN when no
|
|
147
|
+
// visible series has data in the window so the extrema label hides.
|
|
148
|
+
const hasMin = dataMin !== Infinity;
|
|
149
|
+
const hasMax = dataMax !== -Infinity;
|
|
150
|
+
state.extremaMinValue = hasMin ? dataMin : NaN;
|
|
151
|
+
state.extremaMaxValue = hasMax ? dataMax : NaN;
|
|
152
|
+
state.extremaMinTime = hasMin ? minTime : NaN;
|
|
153
|
+
state.extremaMaxTime = hasMax ? maxTime : NaN;
|
|
154
|
+
|
|
121
155
|
const ref = input.referenceValue;
|
|
122
156
|
if (ref !== undefined) {
|
|
123
157
|
if (ref < tMin) tMin = ref;
|