diff --git a/CHANGELOG.md b/CHANGELOG.md index a175ebf..4a4dada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Custom threshold badges.** `LiveChart.renderThresholdBadge` replaces the + built-in Skia threshold pill with any React Native element while the chart + continues to position it from the live threshold value on the UI thread. Its + `ThresholdBadgeRenderProps` expose the live value, formatted value, Y position, + visibility, and resolved line config; returning `null` preserves the built-in + badge, and the dashed marker line remains native. - **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 @@ -16,7 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 5d999e7..2aef71c 100644 --- a/app/demo/threshold.tsx +++ b/app/demo/threshold.tsx @@ -1,6 +1,14 @@ import { useEffect, useState } from "react"; -import { useSharedValue } from "react-native-reanimated"; -import { LiveChart, type LiveChartPoint } from "react-native-livechart"; +import { StyleSheet, Text, TextInput, View } from "react-native"; +import Animated, { + useAnimatedProps, + useSharedValue, +} from "react-native-reanimated"; +import { + LiveChart, + type LiveChartPoint, + type ThresholdBadgeRenderProps, +} from "react-native-livechart"; import { DemoScreen } from "../../demo-lib/DemoScreen"; import { ChipRow, ControlRow, ToggleChip } from "../../demo-lib/ChipRow"; @@ -9,6 +17,8 @@ import { APP_THEME } from "../../demo-lib/theme"; export const options = { title: "Threshold split" }; +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); + const CENTER = 100; type ThresholdType = "benchmark" | "series"; @@ -20,6 +30,31 @@ const ENTRY_LEVELS: Record = { low: CENTER * 0.97, }; +/** + * A realistic custom threshold badge: a brokerage-style average-cost tag with + * an icon, distinct chrome, and a UI-thread value readout. The chart owns its + * position; this component owns only the contents and visual treatment. + */ +function AverageCostBadge({ valueStr }: ThresholdBadgeRenderProps) { + const animatedProps = useAnimatedProps(() => { + const text = valueStr.get(); + return { text, defaultValue: text }; + }); + return ( + + + B/E + + + + ); +} + /** * Smooth, bounded, quasi-random price as a function of time (seconds): a few * incommensurate sines around CENTER. Continuous → no per-tick jumps (the choppy @@ -65,7 +100,8 @@ function useSmoothPriceFeed() { value.set(v); // Commit a point every 3rd tick (~10Hz, matching the seed density) so the // 1000-point cap keeps ~100s of history instead of eroding to ~33s. - if (++tick % 3 !== 0) return; + tick += 1; + if (tick % 3 !== 0) return; const point: LiveChartPoint = { time: now, value: v }; // Append IN PLACE on the UI thread (only `point` crosses the bridge) — never // re-clone the growing array JS→UI, matching the sim's hot path so the line @@ -115,6 +151,8 @@ export default function ThresholdScreen() { const [showValue, setShowValue] = useState(true); const [labelSide, setLabelSide] = useState<"left" | "right">("left"); const [labelAnchor, setLabelAnchor] = useState<"first" | "last">("last"); + const [badgeRenderer, setBadgeRenderer] = + useState<"built-in" | "custom">("built-in"); const [colorMode, setColorMode] = useState<"default" | "custom">("default"); const [entry, setEntry] = useState("start"); @@ -184,6 +222,11 @@ export default function ThresholdScreen() { : true : false, }} + renderThresholdBadge={ + badgeRenderer === "custom" + ? (ctx) => + : undefined + } scrub={false} /> } @@ -245,6 +288,16 @@ export default function ThresholdScreen() { /> )} + + ); } + +const styles = StyleSheet.create({ + averageCostBadge: { + height: 30, + paddingHorizontal: 5, + borderRadius: 8, + borderWidth: 1, + borderColor: "#2dd4bf", + backgroundColor: "#0f172a", + flexDirection: "row", + alignItems: "center", + gap: 6, + shadowColor: "#000", + shadowOpacity: 0.28, + shadowRadius: 5, + shadowOffset: { width: 0, height: 2 }, + }, + averageCostIcon: { + minWidth: 27, + height: 20, + borderRadius: 5, + backgroundColor: "#2dd4bf", + alignItems: "center", + justifyContent: "center", + }, + averageCostIconText: { + color: "#042f2e", + fontSize: 9, + fontWeight: "800", + }, + averageCostValue: { + width: 58, + height: 24, + padding: 0, + color: "#f8fafc", + fontSize: 12, + fontWeight: "700", + fontVariant: ["tabular-nums"], + }, +}); diff --git a/docs/api-reference/livechart.mdx b/docs/api-reference/livechart.mdx index cb1345a..0f80c26 100644 --- a/docs/api-reference/livechart.mdx +++ b/docs/api-reference/livechart.mdx @@ -304,17 +304,26 @@ provided; everything else has a default. Color the line above vs. below a live threshold value — green above, red below - by default. Use a `SharedValue` for a live benchmark or a - `LiveChartPoint[]` / `SharedValue` for a time-varying series. + by default. Use a live scalar `SharedValue`, a `LiveChartPoint[]` + history, or a live `SharedValue` 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 + `line.color`/`colors` and segment recoloring for the main stroke while set. See [`ThresholdConfig`](/api-reference/types#thresholdconfig). Single-series, line mode only. + + Replace the built-in Skia threshold pill with a custom **React Native** + element. The chart measures it and pins it to the threshold's live Y position + and `threshold.line.labelPosition` on the UI thread; the dashed marker line + remains native. The context exposes `value`, `valueStr`, `y`, and `visible` + SharedValues plus the resolved line config. Return `null`/`undefined` to keep + the built-in badge. Requires `threshold.line`. See + [Custom threshold badge](/guides/threshold#custom-threshold-badge). + + ## Scrubbing diff --git a/docs/api-reference/types.mdx b/docs/api-reference/types.mdx index 23141ac..6fa0231 100644 --- a/docs/api-reference/types.mdx +++ b/docs/api-reference/types.mdx @@ -398,6 +398,13 @@ interface ThresholdLineConfig { strokeWidth?: number; // default 1 showValue?: boolean; // append the formatted value to the label; default false } +interface ThresholdBadgeRenderProps { + line: ThresholdLineConfig; // resolved marker-line config + value: SharedValue; // live threshold value + valueStr: SharedValue; // formatted with the chart's formatValue + y: SharedValue; // live canvas Y (NaN when geometry is unavailable) + visible: SharedValue; // whether the badge is inside the plot +} interface BadgeConfig { variant?: "default" | "minimal"; tail?: boolean; // pointed tail toward the dot; false = pill sits flush diff --git a/docs/guides/threshold.mdx b/docs/guides/threshold.mdx index 73fa8f3..e1f5747 100644 --- a/docs/guides/threshold.mdx +++ b/docs/guides/threshold.mdx @@ -85,6 +85,55 @@ threshold={{ While a threshold is set it supersedes `line.color` / `line.colors` and segment recoloring for the main stroke. +## Custom threshold badge + +Use `renderThresholdBadge` when the badge needs React Native content the built-in +Skia pill cannot provide: a branded average-cost tag, icon, status chip, blur, or +an interactive child. The chart still owns the position and follows the live +threshold on the UI thread; your component owns only the badge's contents and +chrome. The dashed marker line remains built in. + +```tsx +import { TextInput, View, Text } from "react-native"; +import Animated, { useAnimatedProps } from "react-native-reanimated"; +import { + LiveChart, + type ThresholdBadgeRenderProps, +} from "react-native-livechart"; + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); + +function AverageCostBadge({ valueStr }: ThresholdBadgeRenderProps) { + const animatedProps = useAnimatedProps(() => { + const text = valueStr.get(); + return { text, defaultValue: text }; + }); + + return ( + + B/E + + + ); +} + + } +/> +``` + +`ThresholdBadgeRenderProps` contains `value`, `valueStr`, `y`, and `visible` +SharedValues plus the resolved `line` config. Bind `valueStr` to animated text as +above so the badge value updates without a React render. Returning `null` or +`undefined` keeps the built-in badge. `renderThresholdBadge` has no effect unless +`threshold.line` is enabled. + ## Time-varying threshold (a series) Pass a `LiveChartPoint[]` as `value` instead of a `SharedValue` and the diff --git a/packages/react-native-livechart/src/components/CustomThresholdBadgeOverlay.tsx b/packages/react-native-livechart/src/components/CustomThresholdBadgeOverlay.tsx new file mode 100644 index 0000000..0936ecf --- /dev/null +++ b/packages/react-native-livechart/src/components/CustomThresholdBadgeOverlay.tsx @@ -0,0 +1,74 @@ +import { StyleSheet, View, type LayoutChangeEvent } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; + +import type { ChartEngineLayout } from "../core/useLiveChartEngine"; +import type { ChartPadding } from "../draw/line"; + +/** Inset from the anchored edge, matching the built-in threshold badge. */ +const EDGE_INSET = 2; + +/** + * React Native overlay for a custom threshold badge. The chart owns the + * position, measuring the returned element and pinning its vertical center to + * the live threshold Y on the UI thread. The consumer owns only its contents and + * chrome. + */ +export function CustomThresholdBadgeOverlay({ + element, + engine, + padding, + y, + visible, + position, +}: { + element: React.ReactElement; + engine: ChartEngineLayout; + padding: ChartPadding; + y: SharedValue; + visible: SharedValue; + position: "left" | "right"; +}) { + const size = useSharedValue({ width: 0, height: 0 }); + const onLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + size.set({ width, height }); + }; + + const animatedStyle = useAnimatedStyle(() => { + const canvasWidth = engine.canvasWidth.get(); + const yy = y.get(); + const measured = size.get(); + const show = visible.get() && canvasWidth > 0 && Number.isFinite(yy); + const translateX = + position === "right" + ? canvasWidth - padding.right - EDGE_INSET - measured.width + : EDGE_INSET; + return { + opacity: show ? 1 : 0, + transform: [ + { translateX }, + { translateY: yy - measured.height / 2 }, + ], + }; + }); + + return ( + + + {element} + + + ); +} + +const styles = StyleSheet.create({ + anchor: { position: "absolute", top: 0, left: 0 }, +}); diff --git a/packages/react-native-livechart/src/components/LiveChart.tsx b/packages/react-native-livechart/src/components/LiveChart.tsx index ff7135c..2e47b6f 100644 --- a/packages/react-native-livechart/src/components/LiveChart.tsx +++ b/packages/react-native-livechart/src/components/LiveChart.tsx @@ -160,6 +160,7 @@ import type { Marker, ReferenceLine, } from "../types"; +import { CustomThresholdBadgeOverlay } from "./CustomThresholdBadgeOverlay"; import { ThresholdBadgeOverlay, ThresholdLineOverlay, @@ -356,6 +357,7 @@ function useLiveChartController({ renderMarker, renderTooltip, renderOverlay, + renderThresholdBadge, renderReferenceLine, renderOffAxisReferenceLine, referenceLineGrouping, @@ -984,6 +986,21 @@ function useLiveChartController({ thresholdCfg && !thresholdIsSeries && !Array.isArray(thresholdCfg.value) ? (thresholdCfg.value ?? thresholdSeriesGeom.badgeValue) : thresholdSeriesGeom.badgeValue; + const hasCustomThresholdBadge = + thresholdCfg?.line != null && renderThresholdBadge != null; + const thresholdMarkerValueStr = useDerivedValue(() => + hasCustomThresholdBadge ? formatValue(thresholdMarkerValue.get()) : "", + ); + const thresholdCustomBadge = + thresholdCfg?.line && renderThresholdBadge + ? renderThresholdBadge({ + line: thresholdCfg.line, + value: thresholdMarkerValue, + valueStr: thresholdMarkerValueStr, + y: thresholdMarkerLineY, + visible: thresholdBadgeVisible, + }) + : null; const thresholdSeriesPts = thresholdIsSeries ? thresholdSeriesGeom.screenPts : undefined; @@ -1543,6 +1560,7 @@ function useLiveChartController({ thresholdMarkerVisible, thresholdBadgeVisible, thresholdMarkerValue, + thresholdCustomBadge, thresholdSeriesPts, badgeUsesRightGutter, // theme / layout / fonts @@ -2258,6 +2276,7 @@ function ChartStack({ thresholdMarkerVisible, thresholdBadgeVisible, thresholdMarkerValue, + thresholdCustomBadge, thresholdSeriesPts, formatValue, lineGroupOpacity, @@ -2484,7 +2503,7 @@ function ChartStack({ {/* Threshold label badge — on top of the line/dot/markers so it's never painted over (the dashed marker line itself stays behind the line, above). */} - {thresholdCfg?.line && ( + {thresholdCfg?.line && thresholdCustomBadge == null && ( )} + {/* Custom threshold badge — a native view positioned from the same live + value/Y SharedValues as the built-in Skia badge. */} + {thresholdCustomBadge && thresholdCfg?.line && ( + + )} + {/* Custom-rendered markers — RN views floated over the canvas (non-Skia), pinned to each marker's live position. Sibling of . Wrapped in a box-none fade layer so `scrub.hideOverlaysOnScrub` hides them with the diff --git a/packages/react-native-livechart/src/index.ts b/packages/react-native-livechart/src/index.ts index 7e80290..3704cb5 100644 --- a/packages/react-native-livechart/src/index.ts +++ b/packages/react-native-livechart/src/index.ts @@ -114,6 +114,7 @@ export type { SeriesConfig, ThemeMode, ThresholdConfig, + ThresholdBadgeRenderProps, ThresholdLineConfig, TooltipRenderProps, TradeEvent, diff --git a/packages/react-native-livechart/src/types.ts b/packages/react-native-livechart/src/types.ts index 21feae1..fbe8156 100644 --- a/packages/react-native-livechart/src/types.ts +++ b/packages/react-native-livechart/src/types.ts @@ -568,6 +568,25 @@ export interface ThresholdLineConfig { labelColor?: string; } +/** + * Context passed to {@link LiveChartProps.renderThresholdBadge}. The chart + * floats the returned React Native element over the canvas and pins it to the + * live threshold on the UI thread. Bind the SharedValues to animated content + * when its displayed value must update without React re-renders. + */ +export interface ThresholdBadgeRenderProps { + /** Resolved marker-line config for the badge being rendered. */ + line: ThresholdLineConfig; + /** Live threshold value in Y-axis units. */ + value: SharedValue; + /** The threshold value formatted with the chart's `formatValue`. */ + valueStr: SharedValue; + /** Canvas Y pixel of the threshold (`NaN` when geometry is unavailable). */ + y: SharedValue; + /** Whether the threshold badge currently belongs inside the visible plot. */ + visible: SharedValue; +} + /** Object form of {@link ThresholdConfig.fill} — band tuning. */ export interface ThresholdFillConfig { /** Band fill opacity (0–1), applied to the above/below colors. Multiplies an @@ -2552,10 +2571,21 @@ export interface LiveChartProps extends LiveChartCoreProps { segments?: ChartSegment[]; /** * Color the line above vs. below a live threshold value (break-even / average - * cost, VWAP, previous close, a peg). Always a `SharedValue` so the split tracks - * live on the UI thread. See {@link ThresholdConfig}. + * cost, VWAP, previous close, a peg). Supports a live scalar or a static/live + * time-varying series. See {@link ThresholdConfig}. */ threshold?: ThresholdConfig; + /** + * Render the threshold line's badge as a custom **React Native** element + * instead of the built-in Skia pill. The chart measures and pins the element + * to the threshold's live Y position and configured `labelPosition` on the UI + * thread (see {@link ThresholdBadgeRenderProps}); the dashed marker line stays + * built in. Requires `threshold.line`. Return `null`/`undefined` to keep the + * built-in badge. Single-series line mode only. + */ + renderThresholdBadge?: ( + ctx: ThresholdBadgeRenderProps, + ) => ReactElement | null | undefined; /** Render the live value as a large text overlay in the top-left. Default `false`. */ showValue?: boolean; /** Tint the `showValue` text by momentum (green up / red down). Default `false`. */ diff --git a/packages/react-native-livechart/tests/LiveChart.test.tsx b/packages/react-native-livechart/tests/LiveChart.test.tsx index 0668fa2..fad637a 100644 --- a/packages/react-native-livechart/tests/LiveChart.test.tsx +++ b/packages/react-native-livechart/tests/LiveChart.test.tsx @@ -723,6 +723,39 @@ describe("LiveChart", () => { ); }); + it("replaces the threshold badge with a custom React Native element", async () => { + let captured: + | Parameters>[0] + | undefined; + const screen = await render( + `$${v.toFixed(2)}`} + renderThresholdBadge={(ctx) => { + captured = ctx; + return ; + }} + />, + ); + expect(screen.getByTestId("custom-threshold-badge")).toBeTruthy(); + expect(captured?.line.labelPosition).toBe("left"); + expect(captured?.value.get()).toBe(12.5); + expect(captured?.valueStr.get()).toBe("$12.50"); + expect(typeof captured?.visible.get()).toBe("boolean"); + }); + + it("keeps the built-in threshold badge when the custom renderer opts out", async () => { + await layoutFirst( + await render( + null} + />, + ), + ); + }); + it("accepts a bare dashed marker line (no label)", async () => { await layoutFirst( await render( diff --git a/packages/react-native-livechart/tests/components/CustomThresholdBadgeOverlay.test.tsx b/packages/react-native-livechart/tests/components/CustomThresholdBadgeOverlay.test.tsx new file mode 100644 index 0000000..0aee7f1 --- /dev/null +++ b/packages/react-native-livechart/tests/components/CustomThresholdBadgeOverlay.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render } from "@testing-library/react-native"; +import { Text } from "react-native"; + +import { CustomThresholdBadgeOverlay } from "../../src/components/CustomThresholdBadgeOverlay"; +import type { ChartEngineLayout } from "../../src/core/useLiveChartEngine"; +import { DEFAULT_PADDING } from "../../src/draw/line"; +import { withSharedValueAccessors } from "../support/sharedValueMock"; + +function sv(value: T) { + return withSharedValueAccessors({ current: { value } }).current as never; +} + +function engine(canvasWidth = 400): ChartEngineLayout { + return withSharedValueAccessors({ + canvasWidth: { value: canvasWidth }, + }) as unknown as ChartEngineLayout; +} + +describe("CustomThresholdBadgeOverlay", () => { + it.each(["left", "right"] as const)( + "floats and measures a custom element on the %s", + async (position) => { + const screen = await render( + Break-even} + engine={engine()} + padding={DEFAULT_PADDING} + y={sv(120)} + visible={sv(true)} + position={position} + />, + ); + const badge = screen.getByTestId("badge"); + await fireEvent(badge.parent!, "layout", { + nativeEvent: { layout: { x: 0, y: 0, width: 80, height: 24 } }, + }); + expect(badge).toBeTruthy(); + }, + ); + + it("keeps the element mounted but hidden before layout/off-axis", async () => { + const screen = await render( + Hidden} + engine={engine(0)} + padding={DEFAULT_PADDING} + y={sv(Number.NaN)} + visible={sv(false)} + position="left" + />, + ); + expect(screen.getByTestId("badge")).toBeTruthy(); + }); +});