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

- **Live dot can track the true live point while scrolled back.**
`DotConfig.trackWhileParked` on `LiveChart` (default `false`) keeps the dot
on the honest live position while time-scrolled or overscrolled: it tracks
the live point's x instead of staying pinned to the plot's right edge, keeps
its heartbeat pulse, and hides once the point leaves the visible window.
Ignored with `badge.followViewEdge` (an edge-pinned dot stays aligned with
its badge). The Time scroll demo and guide include an interactive example.

- **Candle-snapping crosshair.** `ScrubConfig.snapToCandles` quantizes the
scrub X to the hovered candle's center, so the crosshair — and the time,
tooltip, and trailing dim derived from it — jumps candle-to-candle instead
Expand Down
10 changes: 10 additions & 0 deletions app/demo/time-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export default function TimeScrollScreen() {
const [enabled, setEnabled] = useState(true);
const [followViewEdge, setFollowViewEdge] = useState(true);
const [hideLiveOnScrollBack, setHideLiveOnScrollBack] = useState(true);
// Dot tracks the true live point while scrolled back / overscrolled (keeps
// pulsing, hides once the point leaves the window). Ignored while "Follow
// visible edge" is on — an edge-pinned dot stays aligned with its badge.
const [trackWhileParked, setTrackWhileParked] = useState(false);
const [returnMode, setReturnMode] = useState<ReturnMode>("glide");
const [zoomOn, setZoomOn] = useState(true);
const [scrub, setScrub] = useState(true);
Expand Down Expand Up @@ -124,6 +128,7 @@ export default function TimeScrollScreen() {
timeWindow={WINDOW_SECS}
yAxis={{ float: floatAxis }}
badge={{ followViewEdge }}
dot={{ trackWhileParked }}
timeScroll={
enabled
? {
Expand Down Expand Up @@ -166,6 +171,11 @@ export default function TimeScrollScreen() {
value={hideLiveOnScrollBack}
onChange={setHideLiveOnScrollBack}
/>
<ToggleChip
label="Dot tracks live point"
value={trackWhileParked}
onChange={setTrackWhileParked}
/>
</ControlRow>

<ChipRow
Expand Down
6 changes: 5 additions & 1 deletion docs/api-reference/livechart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ provided; everything else has a default.
gives a flat dot, `color` overrides the fill. The same `DotConfig` is shared
with `LiveChartSeries` (whose `dot` extends it with `pulse`, `valueLine`, and
`valueLabel`). `dot={false}` is the canonical "hide" — the deprecated
`dot={{ show: false }}` still works.
`dot={{ show: false }}` still works. With `timeScroll`, `trackWhileParked`
keeps the dot on the **true live point** while scrolled back — it tracks the
point's x, keeps its pulse, and hides once the point leaves the window
(ignored with `badge.followViewEdge`). See
[Time-scroll](/guides/time-scroll#keep-the-dot-on-the-live-point-dottrackwhileparked).
</ParamField>

<ParamField body="valueLine" type="boolean | ValueLineConfig">
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 @@ -540,6 +540,7 @@ interface DotConfig {
ring?: boolean | DotRingConfig; // haloed outer ring, default true
show?: boolean; // @deprecated — prefer dot={false}; default true
color?: string; // dot fill, defaults to the line color
trackWhileParked?: boolean; // while scrolled back / overscrolled, the dot tracks the true live point — keeps pulsing, stays visible, hides once the point leaves the window; ignored with badge.followViewEdge (LiveChart only). Default false
}
interface MultiSeriesDotConfig extends DotConfig {
pulse?: boolean | PulseConfig;
Expand Down
24 changes: 24 additions & 0 deletions docs/guides/time-scroll.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,30 @@ The plotted line is independent of those overlay settings: while scrolled back,
at the visible window's right-edge value and never connects the historical window to the current
live price.

## Keep the dot on the live point (`dot.trackWhileParked`)

The default live dot is pinned to the plot's right edge, so while scrolled back it would sit on a
historical price — it's hidden (and its pulse suppressed) instead. Set
[`dot.trackWhileParked`](/api-reference/types#config-objects) and the dot **tracks the true live
point** while scrolled back or overscrolled: it stays visible at the live point's x, keeps its
heartbeat pulse (it still marks an honest live position), and hides once the live point leaves
the visible window. This is most visible with `overscroll` — drag past the live edge into blank
space and the dot detaches from the plot edge, pulsing at the real last price.

```tsx
<LiveChart
data={data}
value={value}
timeScroll={{ overscroll: 0.5 }}
dot={{ trackWhileParked: true }}
/>
```

Because a tracking dot no longer marks an off-screen price, it is exempt from
`hideLiveOnScrollBack` (the badge and value line keep the default hide). The flag is **ignored
with `badge.followViewEdge`** — an edge-pinned dot must stay aligned with its badge. Off by
default; the example app's **Time scroll** screen includes a **Dot tracks live point** switch.

## Notes

- **Both charts.** `timeScroll` and `zoom` are wired into `LiveChart` and `LiveChartSeries`. In
Expand Down
11 changes: 9 additions & 2 deletions packages/react-native-livechart/src/components/DotOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function DotOverlay({
ring,
color,
viewEnd,
pulseWhileParked = false,
}: {
dotX: SharedValue<number>;
dotY: SharedValue<number>;
Expand All @@ -46,6 +47,12 @@ export function DotOverlay({
* misleading.
*/
viewEnd?: SharedValue<number | null>;
/**
* Keep the heartbeat pulse while scrolled back. Set when the dot tracks the
* true live point (`dot.trackWhileParked`), so the pulse marks an honest
* live position; edge-pinned `followViewEdge` dots keep the suppression.
*/
pulseWhileParked?: boolean;
}) {
const dotColor = color ?? palette.line;

Expand All @@ -64,7 +71,7 @@ export function DotOverlay({

const pulseRadius = useDerivedValue(() => {
if (!pulse) return 0;
if (viewEnd?.value != null) return 0; // scrolled back — no live pulse
if (!pulseWhileParked && viewEnd?.value != null) return 0; // scrolled back — no live pulse
const nowMs = pulseClockMs.value;
const t = (nowMs % pulse.interval) / pulse.duration;
if (t >= 1) return 0;
Expand All @@ -73,7 +80,7 @@ export function DotOverlay({

const pulseOpacity = useDerivedValue(() => {
if (!pulse) return 0;
if (viewEnd?.value != null) return 0; // scrolled back — no live pulse
if (!pulseWhileParked && viewEnd?.value != null) return 0; // scrolled back — no live pulse
const nowMs = pulseClockMs.value;
const t = (nowMs % pulse.interval) / pulse.duration;
if (t >= 1) return 0;
Expand Down
15 changes: 14 additions & 1 deletion packages/react-native-livechart/src/components/LiveChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,10 @@ function useLiveChartController({
// ring never starts (the DotOverlay reads `pulseCfg`, so null = no pulse).
const pulseCfg = isStatic ? null : resolvePulse(pulse);
const dotCfg = resolveDot(dot);
// `badge.followViewEdge` wins over `dot.trackWhileParked`: an edge-pinned dot
// must stay aligned with its badge, so the tracking flag is ignored.
const dotTracksParked =
dotCfg.trackWhileParked && !(badgeCfg?.followViewEdge ?? false);
const selectionDotCfg = resolveSelectionDot(selectionDot);
// Outer footprint of the dot (color-filled radius plus the halo ring).
const dotOuterRadius = dotCfg.radius + (dotCfg.ring?.width ?? 0);
Expand Down Expand Up @@ -944,6 +948,7 @@ function useLiveChartController({
effectivePadding,
engine.edgeValue,
badgeCfg?.followViewEdge ?? false,
dotCfg.trackWhileParked,
);

const momentumSV = useMomentum(engine, momentum);
Expand Down Expand Up @@ -1317,12 +1322,15 @@ function useLiveChartController({
timeScroll,
badgeCfg?.followViewEdge ?? false,
);
// A dot that tracks the true live point while parked is exempt from the
// scroll-back hide: it no longer marks an off-screen price, and it hides
// itself once the live point leaves the window (`useLiveDot`'s sentinel).
const liveDotOpacity = useDerivedValue(
() =>
reveal.dotOpacity.value *
(selectionDotDuringScrub && crosshairScrubActive.value ? 0 : 1) *
liveIndicatorScrollOpacity(
hideLiveOnScrollBack,
hideLiveOnScrollBack && !dotTracksParked,
engine.viewEnd.value,
) *
resolvedSeriesOpacity.value,
Expand Down Expand Up @@ -1398,6 +1406,7 @@ function useLiveChartController({
valueLineCfg,
pulseCfg,
dotCfg,
dotTracksParked,
dotOuterRadius,
gridStyleCfg,
degenCfg,
Expand Down Expand Up @@ -1895,6 +1904,7 @@ function ChartStack({
liveDotOpacity,
pulseCfg,
dotCfg,
dotTracksParked,
degenCfg,
markersActive,
markersSV,
Expand Down Expand Up @@ -2063,6 +2073,9 @@ function ChartStack({
ring={dotCfg.ring}
color={dotCfg.color}
viewEnd={engine.viewEnd}
// A tracking dot marks the honest live position while parked, so
// its heartbeat keeps pulsing (useLiveDot tracks the true point).
pulseWhileParked={dotTracksParked}
/>
</Group>
)}
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 @@ -1205,13 +1205,15 @@ export interface ResolvedDotConfig {
ring: ResolvedDotRingConfig | null;
show: boolean;
color: string | undefined;
trackWhileParked: boolean;
}

const DOT_DEFAULTS: ResolvedDotConfig = {
radius: 3.5,
ring: RING_DEFAULTS,
show: true,
color: undefined,
trackWhileParked: false,
};

/**
Expand All @@ -1231,6 +1233,7 @@ export function resolveDot(
ring: resolveDotRing(prop.ring),
show: prop.show ?? DOT_DEFAULTS.show,
color: prop.color,
trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked,
};
}

Expand Down
31 changes: 28 additions & 3 deletions packages/react-native-livechart/src/hooks/useLiveDot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useDerivedValue, type SharedValue } from "react-native-reanimated";
import type { SingleEngineState } from "../core/useLiveChartEngine";
import type {
ChartEngineScroll,
SingleEngineState,
} from "../core/useLiveChartEngine";
import type { ChartPadding } from "../draw/line";

/**
Expand All @@ -10,17 +13,39 @@ import type { ChartPadding } from "../draw/line";
* With `followViewEdge` + `edgeValue`, the dot (and the value line that shares
* `dotY`) tracks the visible window's right-edge price while scrolled back, so it
* stays aligned with a `followViewEdge` badge instead of marking the live value.
*
* With `trackWhileParked` (`dot.trackWhileParked`), the dot instead tracks the
* **true live point's x** while scrolled back / overscrolled (`viewEnd`
* frozen), and hides once the live point leaves the visible window.
* `followViewEdge` wins when both are set — an edge-pinned dot must stay
* aligned with its badge.
*/
export function useLiveDot(
engine: SingleEngineState,
engine: SingleEngineState & ChartEngineScroll,
padding: ChartPadding,
edgeValue?: SharedValue<number>,
followViewEdge = false,
trackWhileParked = false,
) {
const dotX = useDerivedValue(() => {
const w = engine.canvasWidth.value;
if (w === 0) return -100;
return w - padding.right;
const right = w - padding.right;
// While parked (scrolled back / overscrolled) the live point is not at the
// plot edge — track its real x so a pan doesn't lose the dot, and hide it
// only once the point leaves the window.
if (trackWhileParked && !followViewEdge && engine.viewEnd.value != null) {
const data = engine.data.value;
const last = data[data.length - 1];
const win = engine.displayWindow.value;
const chartW = w - padding.left - padding.right;
if (!last || win <= 0 || chartW <= 0) return -100;
const x =
padding.left +
((last.time - (engine.timestamp.value - win)) / win) * chartW;
return x < padding.left || x > right ? -100 : x;
}
return right;
});

const dotY = useDerivedValue(() => {
Expand Down
15 changes: 15 additions & 0 deletions packages/react-native-livechart/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,21 @@ export interface DotConfig {
show?: boolean;
/** Dot fill color. Defaults to the chart line color (per series for multi-series). */
color?: string;
/**
* Keep the dot on the **true live point** while scrolled back or overscrolled
* with `timeScroll`: it tracks the live point's x (instead of staying pinned
* to the plot's right edge), keeps its heartbeat pulse (the dot still marks
* an honest live position), stays visible under
* `timeScroll.hideLiveOnScrollBack`, and hides once the live point leaves the
* visible window. Ignored when `badge.followViewEdge` is set — an edge-pinned
* dot must stay aligned with its badge. Single-series `LiveChart` only
* (multi-series dots already ride each line's end while scrolled). Default
* `false` (the dot stays pinned to the right edge with its pulse suppressed
* while scrolled back).
*
* @experimental
*/
trackWhileParked?: boolean;
}

/** Live dot configuration for multi-series charts (extends the shared {@link DotConfig}). */
Expand Down
18 changes: 18 additions & 0 deletions packages/react-native-livechart/tests/LiveChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,24 @@ describe("LiveChart", () => {
await render(<Harness gradient={false} yAxis={false} badge={false} />);
});

it("renders with dot.trackWhileParked (on, off, and followViewEdge precedence)", async () => {
const screen = await render(
<Harness timeScroll dot={{ trackWhileParked: true }} />,
);
await screen.rerender(
<Harness timeScroll dot={{ trackWhileParked: false }} />,
);
// `badge.followViewEdge` wins: the edge-pinned dot stays with its badge, so
// the tracking flag (and its pulse/hide exemptions) is ignored.
await screen.rerender(
<Harness
timeScroll
badge={{ followViewEdge: true }}
dot={{ trackWhileParked: true }}
/>,
);
});

it("renders with axisAutoHide enabled (defaults and config object)", async () => {
const screen = await render(<Harness axisAutoHide />);
await screen.rerender(
Expand Down
22 changes: 22 additions & 0 deletions packages/react-native-livechart/tests/components/overlays.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,28 @@ describe("DotOverlay", () => {
await render(<Fixture />);
});

it("keeps the pulse while time-scrolled when pulseWhileParked is set", async () => {
function Fixture() {
const dotX = useSharedValue(100);
const dotY = useSharedValue(120);
const viewEnd = useSharedValue<number | null>(900); // scrolled back
return (
<DotOverlay
dotX={dotX}
dotY={dotY}
palette={palette}
radius={3.5}
ring={{ color: undefined, width: 2.5 }}
color={undefined}
pulse={PULSE_ON}
viewEnd={viewEnd}
pulseWhileParked
/>
);
}
await render(<Fixture />);
});

it("renders with pulse disabled", async () => {
function Fixture() {
const dotX = useSharedValue(100);
Expand Down
Loading
Loading