Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ 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
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;
Expand Down
99 changes: 96 additions & 3 deletions app/demo/threshold.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -20,6 +30,31 @@ const ENTRY_LEVELS: Record<EntryLevel, number> = {
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 (
<View style={styles.averageCostBadge}>
<View style={styles.averageCostIcon}>
<Text style={styles.averageCostIconText}>B/E</Text>
</View>
<AnimatedTextInput
editable={false}
underlineColorAndroid="transparent"
style={styles.averageCostValue}
animatedProps={animatedProps}
/>
</View>
);
}

/**
* Smooth, bounded, quasi-random price as a function of time (seconds): a few
* incommensurate sines around CENTER. Continuous → no per-tick jumps (the choppy
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<EntryLevel>("start");

Expand Down Expand Up @@ -184,6 +222,11 @@ export default function ThresholdScreen() {
: true
: false,
}}
renderThresholdBadge={
badgeRenderer === "custom"
? (ctx) => <AverageCostBadge {...ctx} />
: undefined
}
scrub={false}
/>
}
Expand Down Expand Up @@ -245,6 +288,16 @@ export default function ThresholdScreen() {
/>
)}

<ChipRow
label="Badge renderer"
options={[
{ value: "built-in", label: "Built-in" },
{ value: "custom", label: "Custom RN" },
]}
value={badgeRenderer}
onChange={setBadgeRenderer}
/>

<ChipRow
label="Colors"
options={[
Expand Down Expand Up @@ -280,3 +333,43 @@ export default function ThresholdScreen() {
</DemoScreen>
);
}

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"],
},
});
17 changes: 13 additions & 4 deletions docs/api-reference/livechart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -304,17 +304,26 @@ provided; everything else has a default.

<ParamField body="threshold" type="ThresholdConfig">
Color the line above vs. below a live threshold value — green above, red below
by default. Use a `SharedValue<number>` for a live benchmark or a
`LiveChartPoint[]` / `SharedValue<LiveChartPoint[]>` for a time-varying series.
by default. Use a live scalar `SharedValue<number>`, a `LiveChartPoint[]`
history, or a live `SharedValue<LiveChartPoint[]>` 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.
</ParamField>

<ParamField body="renderThresholdBadge" type="(ctx: ThresholdBadgeRenderProps) => ReactElement | null">
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).
</ParamField>

## Scrubbing

<ParamField body="scrub" type="boolean | ScrubConfig" default="true">
Expand Down
7 changes: 7 additions & 0 deletions docs/api-reference/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>; // live threshold value
valueStr: SharedValue<string>; // formatted with the chart's formatValue
y: SharedValue<number>; // live canvas Y (NaN when geometry is unavailable)
visible: SharedValue<boolean>; // whether the badge is inside the plot
}
interface BadgeConfig {
variant?: "default" | "minimal";
tail?: boolean; // pointed tail toward the dot; false = pill sits flush
Expand Down
49 changes: 49 additions & 0 deletions docs/guides/threshold.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View style={{ flexDirection: "row", backgroundColor: "#0f172a" }}>
<Text>B/E</Text>
<AnimatedTextInput editable={false} animatedProps={animatedProps} />
</View>
);
}

<LiveChart
data={data}
value={value}
threshold={{
value: breakEven,
line: { label: "Break-even", showValue: true },
}}
renderThresholdBadge={(ctx) => <AverageCostBadge {...ctx} />}
/>
```

`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<number>` and the
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number>;
visible: SharedValue<boolean>;
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 (
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
<Animated.View
pointerEvents="box-none"
onLayout={onLayout}
style={[styles.anchor, animatedStyle]}
>
{element}
</Animated.View>
</View>
);
}

const styles = StyleSheet.create({
anchor: { position: "absolute", top: 0, left: 0 },
});
Loading
Loading