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.
Files changed (88) hide show
  1. package/dist/components/CustomThresholdBadgeOverlay.d.ts +18 -0
  2. package/dist/components/CustomThresholdBadgeOverlay.d.ts.map +1 -0
  3. package/dist/components/CustomTooltipOverlay.d.ts +4 -2
  4. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  5. package/dist/components/LiveChart.d.ts +2 -2
  6. package/dist/components/LiveChart.d.ts.map +1 -1
  7. package/dist/components/LiveChartSeries.d.ts +2 -2
  8. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  9. package/dist/components/ReferenceLineOverlay.d.ts +9 -4
  10. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  11. package/dist/components/ReferenceLineSeriesOverlay.d.ts +19 -0
  12. package/dist/components/ReferenceLineSeriesOverlay.d.ts.map +1 -0
  13. package/dist/core/liveChartEngineTick.d.ts +6 -1
  14. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  15. package/dist/core/resolveConfig.d.ts +34 -1
  16. package/dist/core/resolveConfig.d.ts.map +1 -1
  17. package/dist/core/useLiveChartEngine.d.ts +9 -1
  18. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  19. package/dist/draw/candle.d.ts +3 -1
  20. package/dist/draw/candle.d.ts.map +1 -1
  21. package/dist/draw/candleGap.d.ts +14 -0
  22. package/dist/draw/candleGap.d.ts.map +1 -0
  23. package/dist/draw/grid.d.ts +1 -1
  24. package/dist/draw/grid.d.ts.map +1 -1
  25. package/dist/draw/lineGap.d.ts +25 -0
  26. package/dist/draw/lineGap.d.ts.map +1 -0
  27. package/dist/hooks/crosshairShared.d.ts +7 -3
  28. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  29. package/dist/hooks/useCandleGapPaths.d.ts +12 -0
  30. package/dist/hooks/useCandleGapPaths.d.ts.map +1 -0
  31. package/dist/hooks/useChartPaths.d.ts +4 -1
  32. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  33. package/dist/hooks/useCrosshair.d.ts +9 -5
  34. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  35. package/dist/hooks/useLineGapPaths.d.ts +10 -0
  36. package/dist/hooks/useLineGapPaths.d.ts.map +1 -0
  37. package/dist/hooks/usePinchZoom.d.ts +7 -0
  38. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  39. package/dist/hooks/useReferenceLine.d.ts +4 -3
  40. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  41. package/dist/hooks/useReferenceLineSeries.d.ts +23 -0
  42. package/dist/hooks/useReferenceLineSeries.d.ts.map +1 -0
  43. package/dist/hooks/useThreshold.d.ts +13 -13
  44. package/dist/hooks/useThreshold.d.ts.map +1 -1
  45. package/dist/hooks/useYAxis.d.ts +1 -1
  46. package/dist/hooks/useYAxis.d.ts.map +1 -1
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/math/candleGaps.d.ts +18 -0
  50. package/dist/math/candleGaps.d.ts.map +1 -0
  51. package/dist/math/referenceLineSeries.d.ts +12 -0
  52. package/dist/math/referenceLineSeries.d.ts.map +1 -0
  53. package/dist/math/referenceLines.d.ts +5 -5
  54. package/dist/math/referenceLines.d.ts.map +1 -1
  55. package/dist/math/spline.d.ts +5 -1
  56. package/dist/math/spline.d.ts.map +1 -1
  57. package/dist/types.d.ts +212 -14
  58. package/dist/types.d.ts.map +1 -1
  59. package/package.json +1 -1
  60. package/src/components/CustomThresholdBadgeOverlay.tsx +74 -0
  61. package/src/components/CustomTooltipOverlay.tsx +7 -3
  62. package/src/components/LiveChart.tsx +353 -56
  63. package/src/components/LiveChartSeries.tsx +28 -6
  64. package/src/components/ReferenceLineOverlay.tsx +38 -7
  65. package/src/components/ReferenceLineSeriesOverlay.tsx +141 -0
  66. package/src/core/liveChartEngineTick.ts +34 -1
  67. package/src/core/resolveConfig.ts +197 -1
  68. package/src/core/useLiveChartEngine.ts +37 -5
  69. package/src/draw/candle.ts +1 -1
  70. package/src/draw/candleGap.ts +132 -0
  71. package/src/draw/grid.ts +34 -4
  72. package/src/draw/lineGap.ts +180 -0
  73. package/src/hooks/crosshairShared.ts +92 -11
  74. package/src/hooks/useCandleGapPaths.ts +88 -0
  75. package/src/hooks/useChartPaths.ts +81 -22
  76. package/src/hooks/useCrosshair.ts +126 -40
  77. package/src/hooks/useLineGapPaths.ts +76 -0
  78. package/src/hooks/usePinchZoom.ts +21 -0
  79. package/src/hooks/useReferenceLine.ts +6 -5
  80. package/src/hooks/useReferenceLineSeries.ts +101 -0
  81. package/src/hooks/useThreshold.ts +31 -22
  82. package/src/hooks/useYAxis.ts +2 -0
  83. package/src/index.ts +16 -0
  84. package/src/math/candleGaps.ts +120 -0
  85. package/src/math/referenceLineSeries.ts +83 -0
  86. package/src/math/referenceLines.ts +34 -6
  87. package/src/math/spline.ts +15 -5
  88. 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 { useLayoutEffect, useState } from "react";
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 { LiveChartSeriesProps, Marker, SeriesConfig } from "../types";
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 function LiveChartSeries(props: LiveChartSeriesProps) {
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 three
37
- * `ReferenceLine` forms (horizontal line, horizontal value band, vertical time
38
- * band) plus the Form-A pill badge (in-range tag + off-screen chevron pin).
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
- export function ReferenceLineOverlay({
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 ? 1 : 0;
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 ? 1 : 0,
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={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 { ...resolved, count: Math.max(0, Math.floor(resolved.count)) };
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;