react-native-livechart 4.19.0 → 4.21.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/CustomThresholdBadgeOverlay.d.ts +18 -0
- package/dist/components/CustomThresholdBadgeOverlay.d.ts.map +1 -0
- package/dist/components/CustomTooltipOverlay.d.ts +4 -2
- package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
- package/dist/components/LiveChart.d.ts +2 -2
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts +2 -2
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/ReferenceLineOverlay.d.ts +9 -4
- package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
- package/dist/components/ReferenceLineSeriesOverlay.d.ts +19 -0
- package/dist/components/ReferenceLineSeriesOverlay.d.ts.map +1 -0
- package/dist/core/liveChartEngineTick.d.ts +6 -1
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +34 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +9 -1
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/draw/candle.d.ts +3 -1
- package/dist/draw/candle.d.ts.map +1 -1
- package/dist/draw/candleGap.d.ts +14 -0
- package/dist/draw/candleGap.d.ts.map +1 -0
- package/dist/draw/grid.d.ts +1 -1
- package/dist/draw/grid.d.ts.map +1 -1
- package/dist/draw/lineGap.d.ts +25 -0
- package/dist/draw/lineGap.d.ts.map +1 -0
- package/dist/hooks/crosshairShared.d.ts +7 -3
- package/dist/hooks/crosshairShared.d.ts.map +1 -1
- package/dist/hooks/useCandleGapPaths.d.ts +12 -0
- package/dist/hooks/useCandleGapPaths.d.ts.map +1 -0
- package/dist/hooks/useChartPaths.d.ts +4 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/useCrosshair.d.ts +9 -5
- package/dist/hooks/useCrosshair.d.ts.map +1 -1
- package/dist/hooks/useLineGapPaths.d.ts +10 -0
- package/dist/hooks/useLineGapPaths.d.ts.map +1 -0
- package/dist/hooks/usePinchZoom.d.ts +7 -0
- package/dist/hooks/usePinchZoom.d.ts.map +1 -1
- package/dist/hooks/useReferenceLine.d.ts +4 -3
- package/dist/hooks/useReferenceLine.d.ts.map +1 -1
- package/dist/hooks/useReferenceLineSeries.d.ts +23 -0
- package/dist/hooks/useReferenceLineSeries.d.ts.map +1 -0
- package/dist/hooks/useThreshold.d.ts +13 -13
- package/dist/hooks/useThreshold.d.ts.map +1 -1
- package/dist/hooks/useYAxis.d.ts +1 -1
- package/dist/hooks/useYAxis.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/candleGaps.d.ts +18 -0
- package/dist/math/candleGaps.d.ts.map +1 -0
- package/dist/math/referenceLineSeries.d.ts +12 -0
- package/dist/math/referenceLineSeries.d.ts.map +1 -0
- package/dist/math/referenceLines.d.ts +5 -5
- package/dist/math/referenceLines.d.ts.map +1 -1
- package/dist/math/spline.d.ts +5 -1
- package/dist/math/spline.d.ts.map +1 -1
- package/dist/types.d.ts +212 -14
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/CustomThresholdBadgeOverlay.tsx +74 -0
- package/src/components/CustomTooltipOverlay.tsx +7 -3
- package/src/components/LiveChart.tsx +353 -56
- package/src/components/LiveChartSeries.tsx +28 -6
- package/src/components/ReferenceLineOverlay.tsx +38 -7
- package/src/components/ReferenceLineSeriesOverlay.tsx +141 -0
- package/src/core/liveChartEngineTick.ts +34 -1
- package/src/core/resolveConfig.ts +197 -1
- package/src/core/useLiveChartEngine.ts +37 -5
- package/src/draw/candle.ts +1 -1
- package/src/draw/candleGap.ts +132 -0
- package/src/draw/grid.ts +34 -4
- package/src/draw/lineGap.ts +180 -0
- package/src/hooks/crosshairShared.ts +92 -11
- package/src/hooks/useCandleGapPaths.ts +88 -0
- package/src/hooks/useChartPaths.ts +81 -22
- package/src/hooks/useCrosshair.ts +126 -40
- package/src/hooks/useLineGapPaths.ts +76 -0
- package/src/hooks/usePinchZoom.ts +21 -0
- package/src/hooks/useReferenceLine.ts +6 -5
- package/src/hooks/useReferenceLineSeries.ts +101 -0
- package/src/hooks/useThreshold.ts +31 -22
- package/src/hooks/useYAxis.ts +2 -0
- package/src/index.ts +16 -0
- package/src/math/candleGaps.ts +120 -0
- package/src/math/referenceLineSeries.ts +83 -0
- package/src/math/referenceLines.ts +34 -6
- package/src/math/spline.ts +15 -5
- package/src/types.ts +224 -14
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* @see https://github.com/benjitaylor/liveline
|
|
6
6
|
*/
|
|
7
7
|
import { Canvas, Group, Rect } from "@shopify/react-native-skia";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
forwardRef,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useLayoutEffect,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
9
14
|
import { StyleSheet, View } from "react-native";
|
|
10
15
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
11
16
|
import Animated, {
|
|
@@ -16,7 +21,7 @@ import Animated, {
|
|
|
16
21
|
withTiming,
|
|
17
22
|
type SharedValue,
|
|
18
23
|
} from "react-native-reanimated";
|
|
19
|
-
import { scheduleOnRN } from "react-native-worklets";
|
|
24
|
+
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
|
20
25
|
import {
|
|
21
26
|
DEFAULT_ACCENT_COLOR,
|
|
22
27
|
HOLD_TO_SCRUB_MS,
|
|
@@ -62,7 +67,7 @@ import { useMarkers } from "../hooks/useMarkers";
|
|
|
62
67
|
import { useMultiSeriesDegen } from "../hooks/useMultiSeriesDegen";
|
|
63
68
|
import { useMultiSeriesLinePaths } from "../hooks/useMultiSeriesLinePaths";
|
|
64
69
|
import { usePanScroll } from "../hooks/usePanScroll";
|
|
65
|
-
import { usePinchZoom } from "../hooks/usePinchZoom";
|
|
70
|
+
import { resetPinchZoom, usePinchZoom } from "../hooks/usePinchZoom";
|
|
66
71
|
import { useMultiSeriesReverseMorphInputs } from "../hooks/useReverseMorphEngineInputs";
|
|
67
72
|
import {
|
|
68
73
|
SERIES_INDICATOR_FADE_MS,
|
|
@@ -86,7 +91,12 @@ import {
|
|
|
86
91
|
leftEdgeFadeColorsFromBgRgb,
|
|
87
92
|
resolveTheme,
|
|
88
93
|
} from "../theme";
|
|
89
|
-
import type {
|
|
94
|
+
import type {
|
|
95
|
+
LiveChartHandle,
|
|
96
|
+
LiveChartSeriesProps,
|
|
97
|
+
Marker,
|
|
98
|
+
SeriesConfig,
|
|
99
|
+
} from "../types";
|
|
90
100
|
import { AxisLabelOverlay } from "./AxisLabelOverlay";
|
|
91
101
|
import { ChartOverlayLayer } from "./ChartOverlayLayer";
|
|
92
102
|
import {
|
|
@@ -450,6 +460,7 @@ function useLiveChartSeriesController({
|
|
|
450
460
|
yAxisCfg?.minGap ?? 36,
|
|
451
461
|
metricsCfg.grid,
|
|
452
462
|
yAxisCfg?.count ?? 0,
|
|
463
|
+
yAxisCfg?.intervalScale ?? 1,
|
|
453
464
|
);
|
|
454
465
|
|
|
455
466
|
const { xAxisEntries } = useXAxis(
|
|
@@ -1026,8 +1037,19 @@ function SeriesTooltipLayer({
|
|
|
1026
1037
|
);
|
|
1027
1038
|
}
|
|
1028
1039
|
|
|
1029
|
-
export
|
|
1040
|
+
export const LiveChartSeries = forwardRef<
|
|
1041
|
+
LiveChartHandle,
|
|
1042
|
+
LiveChartSeriesProps
|
|
1043
|
+
>(function LiveChartSeries(props, ref) {
|
|
1030
1044
|
const model = useLiveChartSeriesController(props);
|
|
1045
|
+
const { viewEnd, viewWindow } = model.engine;
|
|
1046
|
+
useImperativeHandle(
|
|
1047
|
+
ref,
|
|
1048
|
+
() => ({
|
|
1049
|
+
resetZoom: () => scheduleOnUI(resetPinchZoom, { viewEnd, viewWindow }),
|
|
1050
|
+
}),
|
|
1051
|
+
[viewEnd, viewWindow],
|
|
1052
|
+
);
|
|
1031
1053
|
const {
|
|
1032
1054
|
rootGesture,
|
|
1033
1055
|
backgroundColor,
|
|
@@ -1273,4 +1295,4 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
|
|
|
1273
1295
|
{legendCfg.position === "bottom" ? legend : null}
|
|
1274
1296
|
</View>
|
|
1275
1297
|
);
|
|
1276
|
-
}
|
|
1298
|
+
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { MONO_FONT_FAMILY } from "../lib/monoFontFamily";
|
|
21
21
|
import { referenceLineForm, resolveReferenceBadge } from "../math/referenceLines";
|
|
22
22
|
import type { FontConfig, LiveChartPalette, ReferenceLine } from "../types";
|
|
23
|
+
import { ReferenceLineSeriesOverlay } from "./ReferenceLineSeriesOverlay";
|
|
23
24
|
|
|
24
25
|
/** Translucent fill alpha for value / time bands. */
|
|
25
26
|
const BAND_FILL_OPACITY = 0.16;
|
|
@@ -33,9 +34,10 @@ const CONNECTOR_GAP = 4;
|
|
|
33
34
|
const BADGE_EDGE_INSET = 2;
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* Renders one reference line or band into the chart canvas. Handles all
|
|
37
|
-
* `ReferenceLine` forms (horizontal line,
|
|
38
|
-
* band) plus the Form-A pill badge (in-range tag +
|
|
37
|
+
* Renders one reference line or band into the chart canvas. Handles all four
|
|
38
|
+
* `ReferenceLine` forms (horizontal line, time-varying line, horizontal value
|
|
39
|
+
* band, vertical time band) plus the Form-A pill badge (in-range tag +
|
|
40
|
+
* off-screen chevron pin).
|
|
39
41
|
* Self-contained so callers can `.map()` over a variable-length array.
|
|
40
42
|
*/
|
|
41
43
|
type ReferenceLineOverlayProps = {
|
|
@@ -94,7 +96,28 @@ type ReferenceLineOverlayProps = {
|
|
|
94
96
|
gridEndGap?: number;
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Dispatch to the series or scalar/band renderer without mixing their hook
|
|
101
|
+
* lifecycles. A stable `id` may therefore switch forms safely across renders.
|
|
102
|
+
*/
|
|
103
|
+
export function ReferenceLineOverlay(props: ReferenceLineOverlayProps) {
|
|
104
|
+
if (referenceLineForm(props.line) === "series") {
|
|
105
|
+
return (
|
|
106
|
+
<ReferenceLineSeriesOverlay
|
|
107
|
+
engine={props.engine}
|
|
108
|
+
padding={props.padding}
|
|
109
|
+
line={props.line}
|
|
110
|
+
palette={props.palette}
|
|
111
|
+
formatValue={props.formatValue}
|
|
112
|
+
font={props.font}
|
|
113
|
+
badgeLayer={props.badgeLayer ?? false}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return <ReferenceLineStaticOverlay {...props} />;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ReferenceLineStaticOverlay({
|
|
98
121
|
engine,
|
|
99
122
|
padding,
|
|
100
123
|
line,
|
|
@@ -117,6 +140,8 @@ export function ReferenceLineOverlay({
|
|
|
117
140
|
const isBand = form === "value-band" || form === "time-band";
|
|
118
141
|
|
|
119
142
|
const color = line.color ?? palette.refLine;
|
|
143
|
+
const fillColor = line.fillColor ?? color;
|
|
144
|
+
const strokeOpacity = Math.max(0, Math.min(1, line.strokeOpacity ?? 1));
|
|
120
145
|
|
|
121
146
|
// Resolved badge appearance (badge config → fallback flat fields → theme).
|
|
122
147
|
const badge = resolveReferenceBadge(line);
|
|
@@ -183,6 +208,8 @@ export function ReferenceLineOverlay({
|
|
|
183
208
|
<ReferenceLineBasePass
|
|
184
209
|
layout={layout}
|
|
185
210
|
color={color}
|
|
211
|
+
fillColor={fillColor}
|
|
212
|
+
strokeOpacity={strokeOpacity}
|
|
186
213
|
strokeWidth={strokeWidth}
|
|
187
214
|
intervals={intervals}
|
|
188
215
|
isBand={isBand}
|
|
@@ -221,6 +248,8 @@ export function ReferenceLineOverlay({
|
|
|
221
248
|
function ReferenceLineBasePass({
|
|
222
249
|
layout,
|
|
223
250
|
color,
|
|
251
|
+
fillColor,
|
|
252
|
+
strokeOpacity,
|
|
224
253
|
strokeWidth,
|
|
225
254
|
intervals,
|
|
226
255
|
isBand,
|
|
@@ -230,6 +259,8 @@ function ReferenceLineBasePass({
|
|
|
230
259
|
}: {
|
|
231
260
|
layout: SharedValue<ReferenceLineLayout>;
|
|
232
261
|
color: string;
|
|
262
|
+
fillColor: string;
|
|
263
|
+
strokeOpacity: number;
|
|
233
264
|
strokeWidth: number;
|
|
234
265
|
intervals: [number, number];
|
|
235
266
|
isBand: boolean;
|
|
@@ -282,20 +313,20 @@ function ReferenceLineBasePass({
|
|
|
282
313
|
});
|
|
283
314
|
const lineOpacity = useDerivedValue(() => {
|
|
284
315
|
const l = layout.get();
|
|
285
|
-
return l.visible && l.drawLine && !isBand ?
|
|
316
|
+
return l.visible && l.drawLine && !isBand ? strokeOpacity : 0;
|
|
286
317
|
});
|
|
287
318
|
const bandOpacity = useDerivedValue(() =>
|
|
288
319
|
layout.get().visible && isBand ? bandFillOpacity : 0,
|
|
289
320
|
);
|
|
290
321
|
const bandBorderOpacity = useDerivedValue(() =>
|
|
291
|
-
layout.get().visible && hasBandBorder ?
|
|
322
|
+
layout.get().visible && hasBandBorder ? strokeOpacity : 0,
|
|
292
323
|
);
|
|
293
324
|
|
|
294
325
|
return (
|
|
295
326
|
<Group>
|
|
296
327
|
{isBand && (
|
|
297
328
|
<Group opacity={bandOpacity}>
|
|
298
|
-
<Path path={bandPath} style="fill" color={
|
|
329
|
+
<Path path={bandPath} style="fill" color={fillColor} />
|
|
299
330
|
</Group>
|
|
300
331
|
)}
|
|
301
332
|
{hasBandBorder && (
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DashPathEffect,
|
|
3
|
+
Group,
|
|
4
|
+
Path,
|
|
5
|
+
Text as SkiaText,
|
|
6
|
+
type SkFont,
|
|
7
|
+
} from "@shopify/react-native-skia";
|
|
8
|
+
import { useDerivedValue } from "react-native-reanimated";
|
|
9
|
+
|
|
10
|
+
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
11
|
+
import type { ChartPadding } from "../draw/line";
|
|
12
|
+
import { usePathBuilder } from "../hooks/usePathBuilder";
|
|
13
|
+
import { useReferenceLineSeries } from "../hooks/useReferenceLineSeries";
|
|
14
|
+
import { measureFontTextWidth } from "../lib/measureFontTextWidth";
|
|
15
|
+
import { thresholdDashPhase } from "../math/threshold";
|
|
16
|
+
import type { LiveChartPalette, ReferenceLine } from "../types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Draws Form-B `ReferenceLine.series` geometry. The base pass is a clipped Skia
|
|
20
|
+
* polyline behind the chart; the badge pass is the live-edge label above the
|
|
21
|
+
* chart fade, matching the existing two-pass reference-line layering.
|
|
22
|
+
*/
|
|
23
|
+
export function ReferenceLineSeriesOverlay({
|
|
24
|
+
engine,
|
|
25
|
+
padding,
|
|
26
|
+
line,
|
|
27
|
+
palette,
|
|
28
|
+
formatValue,
|
|
29
|
+
font,
|
|
30
|
+
badgeLayer,
|
|
31
|
+
}: {
|
|
32
|
+
engine: ChartEngineLayout;
|
|
33
|
+
padding: ChartPadding;
|
|
34
|
+
line: ReferenceLine;
|
|
35
|
+
palette: LiveChartPalette;
|
|
36
|
+
formatValue: (value: number) => string;
|
|
37
|
+
font: SkFont;
|
|
38
|
+
badgeLayer: boolean;
|
|
39
|
+
}) {
|
|
40
|
+
const points = line.series ?? [];
|
|
41
|
+
const extendToNow = line.extendToNow ?? true;
|
|
42
|
+
const geometry = useReferenceLineSeries(
|
|
43
|
+
engine,
|
|
44
|
+
padding,
|
|
45
|
+
points,
|
|
46
|
+
extendToNow,
|
|
47
|
+
);
|
|
48
|
+
const color = line.color ?? palette.refLine;
|
|
49
|
+
const labelColor = line.labelColor ?? line.color ?? palette.refLabel;
|
|
50
|
+
const strokeOpacity = Math.max(0, Math.min(1, line.strokeOpacity ?? 1));
|
|
51
|
+
const strokeWidth = line.strokeWidth ?? 1;
|
|
52
|
+
const intervals = line.intervals ?? [4, 4];
|
|
53
|
+
const dashCycle = intervals[0] + intervals[1];
|
|
54
|
+
|
|
55
|
+
const builder = usePathBuilder();
|
|
56
|
+
const path = useDerivedValue(() => {
|
|
57
|
+
const b = builder.value;
|
|
58
|
+
const screen = geometry.screenPts.get();
|
|
59
|
+
if (geometry.visible.get() && screen.length >= 4) {
|
|
60
|
+
b.moveTo(screen[0], screen[1]);
|
|
61
|
+
for (let i = 2; i < screen.length; i += 2) {
|
|
62
|
+
b.lineTo(screen[i], screen[i + 1]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return b.detach();
|
|
66
|
+
});
|
|
67
|
+
const pathOpacity = useDerivedValue(() =>
|
|
68
|
+
geometry.visible.get() ? strokeOpacity : 0,
|
|
69
|
+
);
|
|
70
|
+
const dashPhase = useDerivedValue(() =>
|
|
71
|
+
thresholdDashPhase(
|
|
72
|
+
engine.timestamp.get(),
|
|
73
|
+
engine.displayWindow.get(),
|
|
74
|
+
padding.left,
|
|
75
|
+
engine.canvasWidth.get() - padding.right,
|
|
76
|
+
dashCycle,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
const plotClip = useDerivedValue(() => ({
|
|
80
|
+
x: padding.left,
|
|
81
|
+
y: padding.top,
|
|
82
|
+
width: Math.max(0, engine.canvasWidth.get() - padding.left - padding.right),
|
|
83
|
+
height: Math.max(
|
|
84
|
+
0,
|
|
85
|
+
engine.canvasHeight.get() - padding.top - padding.bottom,
|
|
86
|
+
),
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const metrics = font.getMetrics();
|
|
90
|
+
const baselineOffset = (metrics.ascent + metrics.descent) / 2;
|
|
91
|
+
const labelText = useDerivedValue(() => {
|
|
92
|
+
const value = formatValue(geometry.currentValue.get());
|
|
93
|
+
if (line.label) return line.showValue ? `${line.label} ${value}` : line.label;
|
|
94
|
+
return value;
|
|
95
|
+
});
|
|
96
|
+
const labelX = useDerivedValue(() => {
|
|
97
|
+
const left = padding.left;
|
|
98
|
+
const right = engine.canvasWidth.get() - padding.right;
|
|
99
|
+
const position = line.labelPosition ?? "right";
|
|
100
|
+
if (position === "left") return left + 4;
|
|
101
|
+
if (position === "center") {
|
|
102
|
+
return (left + right) / 2 - measureFontTextWidth(font, labelText.get()) / 2;
|
|
103
|
+
}
|
|
104
|
+
return right + 4;
|
|
105
|
+
});
|
|
106
|
+
const labelY = useDerivedValue(
|
|
107
|
+
() => geometry.currentY.get() - baselineOffset,
|
|
108
|
+
);
|
|
109
|
+
const labelOpacity = useDerivedValue(() =>
|
|
110
|
+
geometry.currentVisible.get() ? 1 : 0,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (badgeLayer) {
|
|
114
|
+
return (
|
|
115
|
+
<Group opacity={labelOpacity}>
|
|
116
|
+
<SkiaText
|
|
117
|
+
x={labelX}
|
|
118
|
+
y={labelY}
|
|
119
|
+
text={labelText}
|
|
120
|
+
font={font}
|
|
121
|
+
color={labelColor}
|
|
122
|
+
/>
|
|
123
|
+
</Group>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Group clip={plotClip} opacity={pathOpacity}>
|
|
129
|
+
<Path
|
|
130
|
+
path={path}
|
|
131
|
+
style="stroke"
|
|
132
|
+
strokeWidth={strokeWidth}
|
|
133
|
+
strokeCap="round"
|
|
134
|
+
strokeJoin="round"
|
|
135
|
+
color={color}
|
|
136
|
+
>
|
|
137
|
+
<DashPathEffect intervals={intervals} phase={dashPhase} />
|
|
138
|
+
</Path>
|
|
139
|
+
</Group>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { CandlePoint, LiveChartPoint } from "../types";
|
|
1
|
+
import type { CandleGap, CandlePoint, LiveChartPoint } from "../types";
|
|
2
2
|
|
|
3
3
|
import { MOTION_METRICS_DEFAULTS } from "../constants";
|
|
4
4
|
import { lerp } from "../math/lerp";
|
|
5
5
|
import { thresholdRangeMinMax } from "../math/threshold";
|
|
6
|
+
import { previousCandleCloseAtTime } from "../math/candleGaps";
|
|
6
7
|
|
|
7
8
|
/** Scratch for the threshold range fold — only alive within one tick call. */
|
|
8
9
|
const THRESHOLD_RANGE_SCRATCH: [number, number] = [0, 0];
|
|
@@ -131,6 +132,11 @@ export interface EngineTickInput {
|
|
|
131
132
|
candles?: CandlePoint[];
|
|
132
133
|
/** In-progress candle. Included in Y range when mode is `"candle"`. */
|
|
133
134
|
liveCandle?: CandlePoint | null;
|
|
135
|
+
/** Explicit semantic gaps used only for previous-close range anchoring. */
|
|
136
|
+
candleGaps?: CandleGap[];
|
|
137
|
+
candleGapBridgeNoTrades?: boolean;
|
|
138
|
+
candleGapBridgeUnavailable?: boolean;
|
|
139
|
+
candleGapBridgeUnknown?: boolean;
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
/**
|
|
@@ -303,6 +309,33 @@ export function tickLiveChartEngineFrame(
|
|
|
303
309
|
state.extremaMinTime = hasMin ? minTime : NaN;
|
|
304
310
|
state.extremaMaxTime = hasMax ? maxTime : NaN;
|
|
305
311
|
|
|
312
|
+
// A previous-close bridge is presentation, not a traded observation: include
|
|
313
|
+
// its anchor in the fitted range (so a viewport parked wholly inside an outage
|
|
314
|
+
// stays framed correctly) only after snapshotting the real-data extrema above.
|
|
315
|
+
if (input.mode === "candle" && input.candleGaps) {
|
|
316
|
+
const cs = input.candles ?? [];
|
|
317
|
+
const lc = input.liveCandle ?? null;
|
|
318
|
+
for (let i = 0; i < input.candleGaps.length; i++) {
|
|
319
|
+
const gap = input.candleGaps[i];
|
|
320
|
+
if (gap.from >= state.timestamp || gap.to <= winStart) continue;
|
|
321
|
+
const bridge =
|
|
322
|
+
gap.kind === "no-trades"
|
|
323
|
+
? input.candleGapBridgeNoTrades
|
|
324
|
+
: gap.kind === "unavailable"
|
|
325
|
+
? input.candleGapBridgeUnavailable
|
|
326
|
+
: input.candleGapBridgeUnknown;
|
|
327
|
+
if (!bridge) continue;
|
|
328
|
+
const anchor = previousCandleCloseAtTime(
|
|
329
|
+
cs,
|
|
330
|
+
lc,
|
|
331
|
+
Math.min(gap.to, state.timestamp),
|
|
332
|
+
);
|
|
333
|
+
if (anchor === null) continue;
|
|
334
|
+
if (anchor < tMin) tMin = anchor;
|
|
335
|
+
if (anchor > tMax) tMax = anchor;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
306
339
|
// Keep the animated live tip inside the range — but only while it's actually
|
|
307
340
|
// in the window. Scrolled back, the live value sits beyond the frozen right
|
|
308
341
|
// edge; folding it in would pin the Y range to the live price and stop the
|
|
@@ -3,6 +3,12 @@ import type {
|
|
|
3
3
|
AxisLabelConfig,
|
|
4
4
|
BadgeConfig,
|
|
5
5
|
BadgeVariant,
|
|
6
|
+
CandleGap,
|
|
7
|
+
CandleGapBandStyle,
|
|
8
|
+
CandleGapBridgeStyle,
|
|
9
|
+
CandleGapKind,
|
|
10
|
+
CandleGapLabelStyle,
|
|
11
|
+
CandleGapsConfig,
|
|
6
12
|
DegenOptions,
|
|
7
13
|
FontConfig,
|
|
8
14
|
FontWeight,
|
|
@@ -89,6 +95,8 @@ export interface ResolvedBadgeConfig {
|
|
|
89
95
|
|
|
90
96
|
export interface ResolvedYAxisConfig {
|
|
91
97
|
minGap: number;
|
|
98
|
+
/** Multiplier used to choose and align representable dynamic nice intervals. */
|
|
99
|
+
intervalScale: number;
|
|
92
100
|
/** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
|
|
93
101
|
count: number;
|
|
94
102
|
/** undefined → keep the default centered-gutter label placement. */
|
|
@@ -112,6 +120,38 @@ export interface ResolvedVolumeConfig {
|
|
|
112
120
|
opacity: number;
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
export interface ResolvedCandleGapBridgeStyle {
|
|
124
|
+
color: string | undefined;
|
|
125
|
+
opacity: number;
|
|
126
|
+
strokeWidth: number;
|
|
127
|
+
strokeCap: "butt" | "round" | "square";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ResolvedCandleGapBandStyle {
|
|
131
|
+
fillColor: string | undefined;
|
|
132
|
+
fillOpacity: number;
|
|
133
|
+
borderColor: string | undefined;
|
|
134
|
+
borderOpacity: number;
|
|
135
|
+
borderWidth: number;
|
|
136
|
+
intervals: [number, number];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ResolvedCandleGapLabelStyle {
|
|
140
|
+
color: string | undefined;
|
|
141
|
+
position: "left" | "right";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ResolvedCandleGapStyle {
|
|
145
|
+
bridge: ResolvedCandleGapBridgeStyle | null;
|
|
146
|
+
band: ResolvedCandleGapBandStyle | null;
|
|
147
|
+
label: ResolvedCandleGapLabelStyle | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ResolvedCandleGapsConfig {
|
|
151
|
+
gaps: CandleGap[];
|
|
152
|
+
styles: Record<CandleGapKind, ResolvedCandleGapStyle>;
|
|
153
|
+
}
|
|
154
|
+
|
|
115
155
|
/** Resolved straight-line styling (connector, etc.). `color: undefined` → caller default. */
|
|
116
156
|
export interface ResolvedLineStyleConfig {
|
|
117
157
|
color: string | undefined;
|
|
@@ -377,6 +417,8 @@ export interface ResolvedThresholdLineConfig {
|
|
|
377
417
|
label: string | undefined;
|
|
378
418
|
/** Label side; `"left"` sits inside the plot (clear of the y-axis gutter). */
|
|
379
419
|
labelPosition: "left" | "right";
|
|
420
|
+
/** Series badge value/Y source; `"last"` preserves the live-edge default. */
|
|
421
|
+
labelAnchor: "first" | "last";
|
|
380
422
|
/** undefined → use palette.refLine (line) / palette.refLabel (label) at render time. */
|
|
381
423
|
color: string | undefined;
|
|
382
424
|
intervals: [number, number];
|
|
@@ -413,6 +455,7 @@ export interface ResolvedThresholdConfig {
|
|
|
413
455
|
const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = {
|
|
414
456
|
label: undefined,
|
|
415
457
|
labelPosition: "left",
|
|
458
|
+
labelAnchor: "last",
|
|
416
459
|
color: undefined,
|
|
417
460
|
intervals: [4, 4],
|
|
418
461
|
strokeWidth: 1,
|
|
@@ -486,6 +529,7 @@ export function resolveBadge(
|
|
|
486
529
|
|
|
487
530
|
const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
|
|
488
531
|
minGap: 36,
|
|
532
|
+
intervalScale: 1,
|
|
489
533
|
count: 0,
|
|
490
534
|
labelRightMargin: undefined,
|
|
491
535
|
gridEndGap: undefined,
|
|
@@ -503,7 +547,14 @@ export function resolveYAxis(
|
|
|
503
547
|
): ResolvedYAxisConfig | null {
|
|
504
548
|
const resolved = resolveToggle(prop, Y_AXIS_DEFAULTS, false);
|
|
505
549
|
if (resolved === null) return null;
|
|
506
|
-
return {
|
|
550
|
+
return {
|
|
551
|
+
...resolved,
|
|
552
|
+
count: Math.max(0, Math.floor(resolved.count)),
|
|
553
|
+
intervalScale:
|
|
554
|
+
Number.isFinite(resolved.intervalScale) && resolved.intervalScale > 0
|
|
555
|
+
? resolved.intervalScale
|
|
556
|
+
: 1,
|
|
557
|
+
};
|
|
507
558
|
}
|
|
508
559
|
|
|
509
560
|
const VOLUME_DEFAULTS: ResolvedVolumeConfig = {
|
|
@@ -525,6 +576,151 @@ export function resolveVolume(
|
|
|
525
576
|
return resolveToggle(prop, VOLUME_DEFAULTS, false);
|
|
526
577
|
}
|
|
527
578
|
|
|
579
|
+
const CANDLE_GAP_BRIDGE_DEFAULTS: ResolvedCandleGapBridgeStyle = {
|
|
580
|
+
color: undefined,
|
|
581
|
+
opacity: 0.7,
|
|
582
|
+
strokeWidth: 2,
|
|
583
|
+
strokeCap: "round",
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const CANDLE_GAP_BAND_DEFAULTS: ResolvedCandleGapBandStyle = {
|
|
587
|
+
fillColor: undefined,
|
|
588
|
+
fillOpacity: 0.12,
|
|
589
|
+
borderColor: undefined,
|
|
590
|
+
borderOpacity: 1,
|
|
591
|
+
borderWidth: 2,
|
|
592
|
+
intervals: [4, 4],
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const CANDLE_GAP_LABEL_DEFAULTS: ResolvedCandleGapLabelStyle = {
|
|
596
|
+
color: undefined,
|
|
597
|
+
position: "left",
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
function clampOpacity(value: number): number {
|
|
601
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const CANDLE_GAP_STYLE_DEFAULTS: Record<CandleGapKind, ResolvedCandleGapStyle> = {
|
|
605
|
+
"no-trades": {
|
|
606
|
+
bridge: CANDLE_GAP_BRIDGE_DEFAULTS,
|
|
607
|
+
band: null,
|
|
608
|
+
label: null,
|
|
609
|
+
},
|
|
610
|
+
unavailable: {
|
|
611
|
+
bridge: { ...CANDLE_GAP_BRIDGE_DEFAULTS, opacity: 0.55 },
|
|
612
|
+
band: CANDLE_GAP_BAND_DEFAULTS,
|
|
613
|
+
label: CANDLE_GAP_LABEL_DEFAULTS,
|
|
614
|
+
},
|
|
615
|
+
unknown: {
|
|
616
|
+
bridge: null,
|
|
617
|
+
band: {
|
|
618
|
+
...CANDLE_GAP_BAND_DEFAULTS,
|
|
619
|
+
fillOpacity: 0.08,
|
|
620
|
+
borderWidth: 1,
|
|
621
|
+
},
|
|
622
|
+
label: CANDLE_GAP_LABEL_DEFAULTS,
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
function resolveCandleGapBridge(
|
|
627
|
+
override: false | CandleGapBridgeStyle | undefined,
|
|
628
|
+
semanticDefault: ResolvedCandleGapBridgeStyle | null,
|
|
629
|
+
): ResolvedCandleGapBridgeStyle | null {
|
|
630
|
+
if (override === false) return null;
|
|
631
|
+
if (override === undefined) {
|
|
632
|
+
return semanticDefault ? { ...semanticDefault } : null;
|
|
633
|
+
}
|
|
634
|
+
const base = semanticDefault ?? CANDLE_GAP_BRIDGE_DEFAULTS;
|
|
635
|
+
const strokeCap = override.strokeCap;
|
|
636
|
+
return {
|
|
637
|
+
color: override.color ?? base.color,
|
|
638
|
+
opacity: clampOpacity(override.opacity ?? base.opacity),
|
|
639
|
+
strokeWidth: Math.max(0, override.strokeWidth ?? base.strokeWidth),
|
|
640
|
+
strokeCap:
|
|
641
|
+
strokeCap === "butt" || strokeCap === "round" || strokeCap === "square"
|
|
642
|
+
? strokeCap
|
|
643
|
+
: base.strokeCap,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function resolveCandleGapBand(
|
|
648
|
+
override: false | CandleGapBandStyle | undefined,
|
|
649
|
+
semanticDefault: ResolvedCandleGapBandStyle | null,
|
|
650
|
+
): ResolvedCandleGapBandStyle | null {
|
|
651
|
+
if (override === false) return null;
|
|
652
|
+
if (override === undefined) {
|
|
653
|
+
return semanticDefault ? { ...semanticDefault } : null;
|
|
654
|
+
}
|
|
655
|
+
const base = semanticDefault ?? CANDLE_GAP_BAND_DEFAULTS;
|
|
656
|
+
return {
|
|
657
|
+
fillColor: override.fillColor ?? base.fillColor,
|
|
658
|
+
fillOpacity: clampOpacity(override.fillOpacity ?? base.fillOpacity),
|
|
659
|
+
borderColor: override.borderColor ?? base.borderColor,
|
|
660
|
+
borderOpacity: clampOpacity(override.borderOpacity ?? base.borderOpacity),
|
|
661
|
+
borderWidth: Math.max(0, override.borderWidth ?? base.borderWidth),
|
|
662
|
+
intervals: override.intervals ?? base.intervals,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function resolveCandleGapLabel(
|
|
667
|
+
override: false | CandleGapLabelStyle | undefined,
|
|
668
|
+
semanticDefault: ResolvedCandleGapLabelStyle | null,
|
|
669
|
+
): ResolvedCandleGapLabelStyle | null {
|
|
670
|
+
if (override === false) return null;
|
|
671
|
+
if (override === undefined) {
|
|
672
|
+
return semanticDefault ? { ...semanticDefault } : null;
|
|
673
|
+
}
|
|
674
|
+
const base = semanticDefault ?? CANDLE_GAP_LABEL_DEFAULTS;
|
|
675
|
+
return {
|
|
676
|
+
color: override.color ?? base.color,
|
|
677
|
+
position:
|
|
678
|
+
override.position === "left" || override.position === "right"
|
|
679
|
+
? override.position
|
|
680
|
+
: base.position,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Resolve and defensively normalize explicit candle-gap metadata. */
|
|
685
|
+
export function resolveCandleGaps(
|
|
686
|
+
prop: CandleGap[] | CandleGapsConfig | undefined,
|
|
687
|
+
): ResolvedCandleGapsConfig | null {
|
|
688
|
+
if (prop == null) return null;
|
|
689
|
+
const gaps = (Array.isArray(prop) ? prop : prop.gaps)
|
|
690
|
+
.filter(
|
|
691
|
+
(gap) =>
|
|
692
|
+
Number.isFinite(gap.from) &&
|
|
693
|
+
Number.isFinite(gap.to) &&
|
|
694
|
+
gap.to > gap.from &&
|
|
695
|
+
(gap.kind === "no-trades" ||
|
|
696
|
+
gap.kind === "unavailable" ||
|
|
697
|
+
gap.kind === "unknown"),
|
|
698
|
+
)
|
|
699
|
+
.slice()
|
|
700
|
+
.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
701
|
+
if (gaps.length === 0) return null;
|
|
702
|
+
|
|
703
|
+
const overrides = Array.isArray(prop) ? undefined : prop.styles;
|
|
704
|
+
const resolveStyle = (kind: CandleGapKind): ResolvedCandleGapStyle => {
|
|
705
|
+
const base = CANDLE_GAP_STYLE_DEFAULTS[kind];
|
|
706
|
+
const override = overrides?.[kind];
|
|
707
|
+
return {
|
|
708
|
+
bridge: resolveCandleGapBridge(override?.bridge, base.bridge),
|
|
709
|
+
band: resolveCandleGapBand(override?.band, base.band),
|
|
710
|
+
label: resolveCandleGapLabel(override?.label, base.label),
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
gaps,
|
|
716
|
+
styles: {
|
|
717
|
+
"no-trades": resolveStyle("no-trades"),
|
|
718
|
+
unavailable: resolveStyle("unavailable"),
|
|
719
|
+
unknown: resolveStyle("unknown"),
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
528
724
|
export interface ResolvedZoomConfig {
|
|
529
725
|
/** Tightest window (max zoom-in), seconds. `undefined` → `timeWindow / 8`. */
|
|
530
726
|
minTimeWindow: number | undefined;
|