From 9d60297b5d29f959652e016171c62447e31c2ffb Mon Sep 17 00:00:00 2001 From: brandtnewlabs <44766428+brandtnewlabs@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:17:26 +0200 Subject: [PATCH] feat: add threshold badge anchor --- CHANGELOG.md | 8 +++ app/demo/threshold.tsx | 20 ++++++- docs/api-reference/livechart.mdx | 10 ++-- docs/api-reference/types.mdx | 1 + docs/guides/threshold.mdx | 28 ++++++++-- .../src/components/LiveChart.tsx | 17 +++--- .../src/core/resolveConfig.ts | 3 ++ .../src/hooks/useThreshold.ts | 53 +++++++++++-------- packages/react-native-livechart/src/types.ts | 7 +++ .../components/ThresholdLineOverlay.test.tsx | 1 + .../tests/hooks/useThreshold.test.tsx | 45 ++++++++++++---- .../tests/resolveConfig.test.ts | 5 ++ 12 files changed, 148 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 306fee53..a175ebf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Configurable threshold series badge anchor.** + `threshold.line.labelAnchor` independently chooses whether a time-varying + threshold badge takes its Y position and optional value from the visible + series' `"first"` (left-edge) or `"last"` (live/right-edge) endpoint, while + `labelPosition` continues to control the badge's horizontal side. The default + remains `"last"` for backward compatibility. Resolves + [#292](https://github.com/brandtnewlabs/react-native-livechart/issues/292). +- **Semantic candle gaps.** `LiveChart.candleGaps` distinguishes known no-trade, - **Semantic candle gaps.** `LiveChart.candleGaps` distinguishes known no-trade, trading-unavailable, and unknown-data intervals without inserting synthetic OHLC records. No-trade and downtime gaps can draw neutral previous-close marks; diff --git a/app/demo/threshold.tsx b/app/demo/threshold.tsx index 6574f9f4..5d999e71 100644 --- a/app/demo/threshold.tsx +++ b/app/demo/threshold.tsx @@ -114,6 +114,7 @@ export default function ThresholdScreen() { const [label, setLabel] = useState(true); const [showValue, setShowValue] = useState(true); const [labelSide, setLabelSide] = useState<"left" | "right">("left"); + const [labelAnchor, setLabelAnchor] = useState<"first" | "last">("last"); const [colorMode, setColorMode] = useState<"default" | "custom">("default"); const [entry, setEntry] = useState("start"); @@ -174,7 +175,12 @@ export default function ThresholdScreen() { // `true` → dashed line only (no text/badge); object → labelled badge. line: markerLine ? label - ? { label: "Break-even", showValue, labelPosition: labelSide } + ? { + label: "Break-even", + showValue, + labelPosition: labelSide, + labelAnchor, + } : true : false, }} @@ -227,6 +233,18 @@ export default function ThresholdScreen() { onChange={setLabelSide} /> + {isSeries && ( + + )} + Color the line above vs. below a live threshold value — green above, red below - by default. The `value` is **always** a `SharedValue`, so the split tracks a - live benchmark (break-even / average cost, VWAP, previous close, a peg) on the - UI thread without re-rendering. Optionally adds a tinted profit/loss `fill` band - and a dashed marker `line`. Supersedes `line.color`/`colors` and segment + by default. Use a `SharedValue` for a live benchmark or a + `LiveChartPoint[]` / `SharedValue` for a time-varying series. + Optionally adds a tinted profit/loss `fill` band and a dashed marker `line`; a + series marker badge can independently select its + horizontal `labelPosition` and first/last visible `labelAnchor`. Supersedes + `line.color`/`colors` and segment recoloring for the main stroke while set. See [`ThresholdConfig`](/api-reference/types#thresholdconfig). Single-series, line mode only. diff --git a/docs/api-reference/types.mdx b/docs/api-reference/types.mdx index 5ef3cacc..23141aca 100644 --- a/docs/api-reference/types.mdx +++ b/docs/api-reference/types.mdx @@ -391,6 +391,7 @@ interface ThresholdConfig { interface ThresholdLineConfig { label?: string; // label text, e.g. "Break-even" labelPosition?: "left" | "right"; // "left" = inside plot (clear of y-axis); default "left" + labelAnchor?: "first" | "last"; // series: visible left-edge or live/right-edge value; default "last" color?: string; // line + label color; default palette refLine/refLabel labelColor?: string; // label text color; falls back to color, then palette refLabel intervals?: [number, number]; // dash pattern; default [4, 4] diff --git a/docs/guides/threshold.mdx b/docs/guides/threshold.mdx index 19c79a09..73fa8f3e 100644 --- a/docs/guides/threshold.mdx +++ b/docs/guides/threshold.mdx @@ -64,9 +64,26 @@ below; override with `aboveColor` / `belowColor`). Two opt-in extras share the s The label renders as an opaque **badge** anchored flush to the plot's left edge (`labelPosition: "left"`, the default — clear of the y-axis labels) or the right -gutter, and is drawn on top of the line so it's never painted over. While a -threshold is set it supersedes `line.color` / `line.colors` and segment recoloring -for the main stroke. +gutter, and is drawn on top of the line so it's never painted over. For a +time-varying series, `labelAnchor` independently selects the threshold value and +Y position: `"first"` samples the visible window's left edge and `"last"` (the +default) samples the live/right edge. This lets a left-positioned badge line up +with the threshold where it enters the plot: + +```tsx +threshold={{ + series: vwap, + line: { + label: "VWAP", + showValue: true, + labelPosition: "left", + labelAnchor: "first", + }, +}} +``` + +While a threshold is set it supersedes `line.color` / `line.colors` and segment +recoloring for the main stroke. ## Time-varying threshold (a series) @@ -108,8 +125,9 @@ const breakEven: LiveChartPoint[] = [ `rgba()`** in series mode (an `rgba()` alpha carries into the stroke and scales the band). Named CSS colors and 8-digit hex are only supported by the constant form — in series mode they fall back to grey. -- The marker `line`'s badge shows the threshold's **current** value (at the live - edge) when `showValue` is set. +- The marker `line`'s badge shows the threshold's live-edge value by default. + Set `labelAnchor: "first"` to show and align to the value at the visible + window's left edge instead; `labelPosition` still controls the badge's X side. - An **empty series** (`[]`) renders as "no threshold yet": plain line color, no band, no marker — handy while the threshold history is still loading. - **`extendToNow: false`** opts out of the flat extension for a benchmark that diff --git a/packages/react-native-livechart/src/components/LiveChart.tsx b/packages/react-native-livechart/src/components/LiveChart.tsx index f6cfb102..ff7135c5 100644 --- a/packages/react-native-livechart/src/components/LiveChart.tsx +++ b/packages/react-native-livechart/src/components/LiveChart.tsx @@ -906,6 +906,7 @@ function useLiveChartController({ thresholdValue, thresholdSeriesSV, thresholdCfg?.extendToNow ?? true, + thresholdCfg?.line?.labelAnchor ?? "last", ); const thresholdStopColors = thresholdCfg ? thresholdStops(thresholdCfg, palette) @@ -966,23 +967,23 @@ function useLiveChartController({ thresholdSeriesGeom.clipRightX, ); - // Marker line + badge sources: the series anchors at the value-at-now (flat- - // extended past its last point); the constant case at the single benchmark Y. - // The badge gets its own visibility — it's pinned at the value-at-now Y, which - // can be off-plot while older polyline segments are still visible. + // Marker line + badge sources: a series badge independently selects its first + // or last visible endpoint; the constant case stays at the single benchmark Y. + // The badge gets its own visibility because its selected endpoint can be + // off-plot while older polyline segments are still visible. const thresholdMarkerLineY = thresholdIsSeries - ? thresholdSeriesGeom.currentLineY + ? thresholdSeriesGeom.badgeLineY : thresholdGeom.lineY; const thresholdMarkerVisible = thresholdIsSeries ? thresholdSeriesGeom.visible : thresholdGeom.visible; const thresholdBadgeVisible = thresholdIsSeries - ? thresholdSeriesGeom.currentVisible + ? thresholdSeriesGeom.badgeVisible : thresholdGeom.visible; const thresholdMarkerValue = thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value) - ? (thresholdCfg.value ?? thresholdSeriesGeom.currentValue) - : thresholdSeriesGeom.currentValue; + ? (thresholdCfg.value ?? thresholdSeriesGeom.badgeValue) + : thresholdSeriesGeom.badgeValue; const thresholdSeriesPts = thresholdIsSeries ? thresholdSeriesGeom.screenPts : undefined; diff --git a/packages/react-native-livechart/src/core/resolveConfig.ts b/packages/react-native-livechart/src/core/resolveConfig.ts index e09b7ff9..37bb1b40 100644 --- a/packages/react-native-livechart/src/core/resolveConfig.ts +++ b/packages/react-native-livechart/src/core/resolveConfig.ts @@ -417,6 +417,8 @@ export interface ResolvedThresholdLineConfig { label: string | undefined; /** Label side; `"left"` sits inside the plot (clear of the y-axis gutter). */ labelPosition: "left" | "right"; + /** Series badge value/Y source; `"last"` preserves the live-edge default. */ + labelAnchor: "first" | "last"; /** undefined → use palette.refLine (line) / palette.refLabel (label) at render time. */ color: string | undefined; intervals: [number, number]; @@ -453,6 +455,7 @@ export interface ResolvedThresholdConfig { const THRESHOLD_LINE_DEFAULTS: ResolvedThresholdLineConfig = { label: undefined, labelPosition: "left", + labelAnchor: "last", color: undefined, intervals: [4, 4], strokeWidth: 1, diff --git a/packages/react-native-livechart/src/hooks/useThreshold.ts b/packages/react-native-livechart/src/hooks/useThreshold.ts index 5a879257..4bf69aff 100644 --- a/packages/react-native-livechart/src/hooks/useThreshold.ts +++ b/packages/react-native-livechart/src/hooks/useThreshold.ts @@ -95,15 +95,15 @@ export interface ThresholdSeriesGeometry { samples: SharedValue; /** Whether any of the polyline is on-screen (drives marker-line opacity). */ visible: SharedValue; - /** Threshold value at `now` (flat-extended past the last point) — the badge label. */ - currentValue: SharedValue; - /** Pixel-Y of `currentValue` — anchors the badge. */ - currentLineY: SharedValue; - /** Whether the badge should show: `currentLineY` on-plot AND, with - * `extendToNow` off, "now" not past the series' last point. (`visible` can be - * true for the polyline while the value-at-now sits outside the plot; the - * badge must not draw into the gutters then.) */ - currentVisible: SharedValue; + /** Threshold value at the configured badge anchor: the visible window's + * left edge (`"first"`) or its live/right edge (`"last"`). */ + badgeValue: SharedValue; + /** Pixel-Y of `badgeValue` — anchors the badge. */ + badgeLineY: SharedValue; + /** Whether the configured badge anchor is on-plot and belongs to a visible + * part of the threshold. (`visible` can still be true when that one endpoint + * is off-plot.) */ + badgeVisible: SharedValue; /** Pixel-X where the threshold ends: the last point's X with `extendToNow` * off, else {@link THRESHOLD_NO_CLIP}. The shader paints its plain * `restColor` right of it; the marker polyline stops there. */ @@ -118,9 +118,9 @@ const EMPTY_SAMPLES: number[] = new Array(THRESHOLD_SAMPLE_COUNT).fill(0); * Per-frame screen geometry for a **time-varying** threshold — a plain * `LiveChartPoint[]` `value` or a live `SharedValue` `series` * (which wins when both are given): the screen polyline (marker line + - * fill-band bottom), the shader's pixel-Y `samples[]`, the current value/anchor - * for the badge, and the `extendToNow` cutoff X. The array buffers ping-pong - * (Reanimated only re-notifies subscribers when the returned reference + * fill-band bottom), the shader's pixel-Y `samples[]`, the configured endpoint + * value/anchor for the badge, and the `extendToNow` cutoff X. The array buffers + * ping-pong (Reanimated only re-notifies subscribers when the returned reference * changes). When the threshold is a constant `SharedValue` every * worklet short-circuits cheaply and {@link useThreshold} drives the render * instead. @@ -131,6 +131,7 @@ export function useThresholdSeries( value: ThresholdValue, series: SharedValue | null = null, extendToNow = true, + labelAnchor: "first" | "last" = "last", ): ThresholdSeriesGeometry { const cacheRef = useRef<{ ptsA: number[]; @@ -238,15 +239,19 @@ export function useThresholdSeries( ); }); - const currentValue = useDerivedValue(() => { + const badgeValue = useDerivedValue(() => { const pts = series ? series.get() : Array.isArray(value) ? value : null; if (pts === null) return NaN; - return interpolateAtTime(pts, engine.timestamp.get()) ?? NaN; + const time = + labelAnchor === "first" + ? engine.timestamp.get() - engine.displayWindow.get() + : engine.timestamp.get(); + return interpolateAtTime(pts, time) ?? NaN; }); - const currentLineY = useDerivedValue(() => + const badgeLineY = useDerivedValue(() => thresholdLineY( - currentValue.get(), + badgeValue.get(), engine.displayMin.get(), engine.displayMax.get(), engine.canvasHeight.get(), @@ -255,15 +260,19 @@ export function useThresholdSeries( ), ); - const currentVisible = useDerivedValue(() => { - if (!extendToNow) { + const badgeVisible = useDerivedValue(() => { + if (labelAnchor === "first") { + // If the non-extended series ended before this window, there is no left + // endpoint to label even though interpolation can still clamp a value. + if (screenPts.get().length < 4) return false; + } else if (!extendToNow) { // The threshold ends at its last point — no badge past it. const pts = series ? series.get() : Array.isArray(value) ? value : null; if (pts === null || pts.length === 0) return false; if (pts[pts.length - 1].time < engine.timestamp.get()) return false; } return thresholdVisible( - currentLineY.get(), + badgeLineY.get(), engine.canvasHeight.get(), padding.top, padding.bottom, @@ -274,9 +283,9 @@ export function useThresholdSeries( screenPts, samples, visible, - currentValue, - currentLineY, - currentVisible, + badgeValue, + badgeLineY, + badgeVisible, clipRightX, }; } diff --git a/packages/react-native-livechart/src/types.ts b/packages/react-native-livechart/src/types.ts index d193941a..21feae1c 100644 --- a/packages/react-native-livechart/src/types.ts +++ b/packages/react-native-livechart/src/types.ts @@ -548,6 +548,13 @@ export interface ThresholdLineConfig { * gutter like a legacy reference line (may overlap y-axis labels). Default `"left"`. */ labelPosition?: "left" | "right"; + /** + * Time-varying threshold series only: which visible endpoint supplies the + * label badge's Y position and optional value. `"first"` samples the + * threshold at the visible window's left edge; `"last"` uses the live/right + * edge. Independent of {@link labelPosition}. Default `"last"`. + */ + labelAnchor?: "first" | "last"; /** Line + label color. Defaults to palette `refLine` / `refLabel`. */ color?: string; /** Dash pattern `[dashLength, gapLength]` in pixels. Default `[4, 4]`. */ diff --git a/packages/react-native-livechart/tests/components/ThresholdLineOverlay.test.tsx b/packages/react-native-livechart/tests/components/ThresholdLineOverlay.test.tsx index 66e999ca..638c907f 100644 --- a/packages/react-native-livechart/tests/components/ThresholdLineOverlay.test.tsx +++ b/packages/react-native-livechart/tests/components/ThresholdLineOverlay.test.tsx @@ -32,6 +32,7 @@ function engine(): ChartEngineLayout { const LINE_DEFAULTS: ResolvedThresholdLineConfig = { label: undefined, labelPosition: "left", + labelAnchor: "last", color: undefined, labelColor: undefined, intervals: [4, 4], diff --git a/packages/react-native-livechart/tests/hooks/useThreshold.test.tsx b/packages/react-native-livechart/tests/hooks/useThreshold.test.tsx index fdb616c9..6ca59d55 100644 --- a/packages/react-native-livechart/tests/hooks/useThreshold.test.tsx +++ b/packages/react-native-livechart/tests/hooks/useThreshold.test.tsx @@ -52,8 +52,8 @@ describe("useThresholdSeries (time-varying)", () => { expect(result.current.screenPts.value.length).toBeGreaterThanOrEqual(4); expect(result.current.samples.value).toHaveLength(THRESHOLD_SAMPLE_COUNT); // value-at-now clamps to the last point (55). - expect(result.current.currentValue.value).toBeCloseTo(55); - expect(result.current.currentVisible.value).toBe(true); + expect(result.current.badgeValue.value).toBeCloseTo(55); + expect(result.current.badgeVisible.value).toBe(true); // Polyline is pinned to the exact plot edges (stable dash anchor). const pts = result.current.screenPts.value; expect(pts[0]).toBe(DEFAULT_PADDING.left); @@ -74,8 +74,33 @@ describe("useThresholdSeries (time-varying)", () => { useThresholdSeries(engine(), DEFAULT_PADDING, stepped), ); expect(result.current.visible.value).toBe(true); - expect(result.current.currentValue.value).toBeCloseTo(500); - expect(result.current.currentVisible.value).toBe(false); + expect(result.current.badgeValue.value).toBeCloseTo(500); + expect(result.current.badgeVisible.value).toBe(false); + }); + + it("can anchor the badge to the first visible threshold value", async () => { + const { result } = await renderHook(() => + useThresholdSeries( + engine(), + DEFAULT_PADDING, + series, + null, + true, + "first", + ), + ); + // The visible window starts at t=900, where the series value is 40. The + // default `last` anchor would use the t=1000 value (55). + expect(result.current.badgeValue.value).toBeCloseTo(40); + expect(result.current.badgeLineY.value).toBeCloseTo(168); + expect(result.current.badgeVisible.value).toBe(true); + }); + + it("hides a first-anchored badge when the threshold has no visible segment", async () => { + const { result } = await renderHook(() => + useThresholdSeries(engine(), DEFAULT_PADDING, [], null, false, "first"), + ); + expect(result.current.badgeVisible.value).toBe(false); }); it("short-circuits to empty geometry for a constant value", async () => { @@ -84,15 +109,15 @@ describe("useThresholdSeries (time-varying)", () => { ); expect(result.current.screenPts.value).toEqual([]); expect(result.current.visible.value).toBe(false); - expect(result.current.currentValue.value).toBeNaN(); + expect(result.current.badgeValue.value).toBeNaN(); }); - it("yields a NaN current value for an empty series", async () => { + it("yields a NaN badge value for an empty series", async () => { const { result } = await renderHook(() => useThresholdSeries(engine(), DEFAULT_PADDING, []), ); expect(result.current.screenPts.value).toEqual([]); - expect(result.current.currentValue.value).toBeNaN(); + expect(result.current.badgeValue.value).toBeNaN(); }); it("reads a live SharedValue series (threshold.series form)", async () => { @@ -107,7 +132,7 @@ describe("useThresholdSeries (time-varying)", () => { useThresholdSeries(engine(), DEFAULT_PADDING, useSharedValue(0), seriesSV), ); expect(result.current.samples.value).toHaveLength(THRESHOLD_SAMPLE_COUNT); - expect(result.current.currentValue.value).toBeCloseTo(55); + expect(result.current.badgeValue.value).toBeCloseTo(55); expect(result.current.visible.value).toBe(true); }); @@ -126,7 +151,7 @@ describe("useThresholdSeries (time-varying)", () => { const pts = result.current.screenPts.value; expect(pts[pts.length - 2]).toBeCloseTo(200); // Badge hidden: "now" is past the series end. - expect(result.current.currentVisible.value).toBe(false); + expect(result.current.badgeVisible.value).toBe(false); }); it("extendToNow=true (default): no clip, badge shows", async () => { @@ -140,6 +165,6 @@ describe("useThresholdSeries (time-varying)", () => { expect(result.current.clipRightX.value).toBe(1e9); const pts = result.current.screenPts.value; expect(pts[pts.length - 2]).toBe(400 - DEFAULT_PADDING.right); - expect(result.current.currentVisible.value).toBe(true); + expect(result.current.badgeVisible.value).toBe(true); }); }); diff --git a/packages/react-native-livechart/tests/resolveConfig.test.ts b/packages/react-native-livechart/tests/resolveConfig.test.ts index 81c2dc7d..1c0e5e52 100644 --- a/packages/react-native-livechart/tests/resolveConfig.test.ts +++ b/packages/react-native-livechart/tests/resolveConfig.test.ts @@ -1871,6 +1871,7 @@ describe("resolveThresholdLine", () => { expect(resolveThresholdLine(true)).toEqual({ label: undefined, labelPosition: "left", + labelAnchor: "last", color: undefined, labelColor: undefined, intervals: [4, 4], @@ -1884,6 +1885,7 @@ describe("resolveThresholdLine", () => { resolveThresholdLine({ label: "Break-even", labelPosition: "right", + labelAnchor: "first", color: "#0f0", intervals: [6, 3], strokeWidth: 2, @@ -1892,6 +1894,7 @@ describe("resolveThresholdLine", () => { ).toEqual({ label: "Break-even", labelPosition: "right", + labelAnchor: "first", color: "#0f0", labelColor: undefined, intervals: [6, 3], @@ -1987,6 +1990,7 @@ describe("resolveThreshold", () => { expect(r?.line).toEqual({ label: undefined, labelPosition: "left", + labelAnchor: "last", color: undefined, labelColor: undefined, intervals: [4, 4], @@ -1999,6 +2003,7 @@ describe("resolveThreshold", () => { expect(resolveThreshold({ value, line: { label: "VWAP" } })?.line).toEqual({ label: "VWAP", labelPosition: "left", + labelAnchor: "last", color: undefined, intervals: [4, 4], strokeWidth: 1,