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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion app/demo/threshold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EntryLevel>("start");

Expand Down Expand Up @@ -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,
}}
Expand Down Expand Up @@ -227,6 +233,18 @@ export default function ThresholdScreen() {
onChange={setLabelSide}
/>

{isSeries && (
<ChipRow
label="Label anchor"
options={[
{ value: "first", label: "First" },
{ value: "last", label: "Last" },
]}
value={labelAnchor}
onChange={setLabelAnchor}
/>
)}

<ChipRow
label="Colors"
options={[
Expand Down
10 changes: 6 additions & 4 deletions docs/api-reference/livechart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,12 @@ 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. 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<number>` for a live benchmark or a
`LiveChartPoint[]` / `SharedValue<LiveChartPoint[]>` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
28 changes: 23 additions & 5 deletions docs/guides/threshold.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions packages/react-native-livechart/src/components/LiveChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,7 @@ function useLiveChartController({
thresholdValue,
thresholdSeriesSV,
thresholdCfg?.extendToNow ?? true,
thresholdCfg?.line?.labelAnchor ?? "last",
);
const thresholdStopColors = thresholdCfg
? thresholdStops(thresholdCfg, palette)
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native-livechart/src/core/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 31 additions & 22 deletions packages/react-native-livechart/src/hooks/useThreshold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,15 @@ export interface ThresholdSeriesGeometry {
samples: SharedValue<number[]>;
/** Whether any of the polyline is on-screen (drives marker-line opacity). */
visible: SharedValue<boolean>;
/** Threshold value at `now` (flat-extended past the last point) — the badge label. */
currentValue: SharedValue<number>;
/** Pixel-Y of `currentValue` — anchors the badge. */
currentLineY: SharedValue<number>;
/** 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<boolean>;
/** Threshold value at the configured badge anchor: the visible window's
* left edge (`"first"`) or its live/right edge (`"last"`). */
badgeValue: SharedValue<number>;
/** Pixel-Y of `badgeValue` — anchors the badge. */
badgeLineY: SharedValue<number>;
/** 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<boolean>;
/** 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. */
Expand All @@ -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<LiveChartPoint[]>` `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<number>` every
* worklet short-circuits cheaply and {@link useThreshold} drives the render
* instead.
Expand All @@ -131,6 +131,7 @@ export function useThresholdSeries(
value: ThresholdValue,
series: SharedValue<LiveChartPoint[]> | null = null,
extendToNow = true,
labelAnchor: "first" | "last" = "last",
): ThresholdSeriesGeometry {
const cacheRef = useRef<{
ptsA: number[];
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -274,9 +283,9 @@ export function useThresholdSeries(
screenPts,
samples,
visible,
currentValue,
currentLineY,
currentVisible,
badgeValue,
badgeLineY,
badgeVisible,
clipRightX,
};
}
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native-livechart/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function engine(): ChartEngineLayout {
const LINE_DEFAULTS: ResolvedThresholdLineConfig = {
label: undefined,
labelPosition: "left",
labelAnchor: "last",
color: undefined,
labelColor: undefined,
intervals: [4, 4],
Expand Down
45 changes: 35 additions & 10 deletions packages/react-native-livechart/tests/hooks/useThreshold.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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);
});

Expand All @@ -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 () => {
Expand All @@ -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);
});
});
Loading
Loading