From e63e3e93bfc843be72deeb2a6a5bc4bc16fd665f Mon Sep 17 00:00:00 2001 From: Ian Lapham Date: Tue, 11 Aug 2026 13:58:21 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20dot.trackWhileParked=20=E2=80=94?= =?UTF-8?q?=20dot=20follows=20the=20live=20point=20while=20scrolled=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ app/demo/time-scroll.tsx | 10 ++ docs/api-reference/livechart.mdx | 6 +- docs/api-reference/types.mdx | 1 + docs/guides/time-scroll.mdx | 24 +++ .../src/components/DotOverlay.tsx | 11 +- .../src/components/LiveChart.tsx | 15 +- .../src/core/resolveConfig.ts | 3 + .../src/hooks/useLiveDot.ts | 31 +++- packages/react-native-livechart/src/types.ts | 15 ++ .../tests/LiveChart.test.tsx | 18 +++ .../tests/components/overlays.test.tsx | 22 +++ .../tests/hooks/useLiveDot.test.tsx | 152 +++++++++++++++++- .../tests/resolveConfig.test.ts | 23 ++- 14 files changed, 327 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d222b58d..9ea5d59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [4.17.0] - 2026-08-10 ### Added diff --git a/app/demo/time-scroll.tsx b/app/demo/time-scroll.tsx index a7728da4..d80d3be4 100644 --- a/app/demo/time-scroll.tsx +++ b/app/demo/time-scroll.tsx @@ -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("glide"); const [zoomOn, setZoomOn] = useState(true); const [scrub, setScrub] = useState(true); @@ -124,6 +128,7 @@ export default function TimeScrollScreen() { timeWindow={WINDOW_SECS} yAxis={{ float: floatAxis }} badge={{ followViewEdge }} + dot={{ trackWhileParked }} timeScroll={ enabled ? { @@ -166,6 +171,11 @@ export default function TimeScrollScreen() { value={hideLiveOnScrollBack} onChange={setHideLiveOnScrollBack} /> + diff --git a/docs/api-reference/types.mdx b/docs/api-reference/types.mdx index 306acf6c..6e1d52da 100644 --- a/docs/api-reference/types.mdx +++ b/docs/api-reference/types.mdx @@ -538,6 +538,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; diff --git a/docs/guides/time-scroll.mdx b/docs/guides/time-scroll.mdx index b1296bdd..b6f3aa9c 100644 --- a/docs/guides/time-scroll.mdx +++ b/docs/guides/time-scroll.mdx @@ -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 + +``` + +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 diff --git a/packages/react-native-livechart/src/components/DotOverlay.tsx b/packages/react-native-livechart/src/components/DotOverlay.tsx index b6079df5..d0ade098 100644 --- a/packages/react-native-livechart/src/components/DotOverlay.tsx +++ b/packages/react-native-livechart/src/components/DotOverlay.tsx @@ -29,6 +29,7 @@ export function DotOverlay({ ring, color, viewEnd, + pulseWhileParked = false, }: { dotX: SharedValue; dotY: SharedValue; @@ -46,6 +47,12 @@ export function DotOverlay({ * misleading. */ viewEnd?: SharedValue; + /** + * 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; @@ -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; @@ -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; diff --git a/packages/react-native-livechart/src/components/LiveChart.tsx b/packages/react-native-livechart/src/components/LiveChart.tsx index af98bcdb..bf2969ad 100644 --- a/packages/react-native-livechart/src/components/LiveChart.tsx +++ b/packages/react-native-livechart/src/components/LiveChart.tsx @@ -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); @@ -944,6 +948,7 @@ function useLiveChartController({ effectivePadding, engine.edgeValue, badgeCfg?.followViewEdge ?? false, + dotCfg.trackWhileParked, ); const momentumSV = useMomentum(engine, momentum); @@ -1315,12 +1320,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, @@ -1396,6 +1404,7 @@ function useLiveChartController({ valueLineCfg, pulseCfg, dotCfg, + dotTracksParked, dotOuterRadius, gridStyleCfg, degenCfg, @@ -1893,6 +1902,7 @@ function ChartStack({ liveDotOpacity, pulseCfg, dotCfg, + dotTracksParked, degenCfg, markersActive, markersSV, @@ -2061,6 +2071,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} /> )} diff --git a/packages/react-native-livechart/src/core/resolveConfig.ts b/packages/react-native-livechart/src/core/resolveConfig.ts index 22bbb50f..7363a755 100644 --- a/packages/react-native-livechart/src/core/resolveConfig.ts +++ b/packages/react-native-livechart/src/core/resolveConfig.ts @@ -1202,6 +1202,7 @@ export interface ResolvedDotConfig { ring: ResolvedDotRingConfig | null; show: boolean; color: string | undefined; + trackWhileParked: boolean; } const DOT_DEFAULTS: ResolvedDotConfig = { @@ -1209,6 +1210,7 @@ const DOT_DEFAULTS: ResolvedDotConfig = { ring: RING_DEFAULTS, show: true, color: undefined, + trackWhileParked: false, }; /** @@ -1228,6 +1230,7 @@ export function resolveDot( ring: resolveDotRing(prop.ring), show: prop.show ?? DOT_DEFAULTS.show, color: prop.color, + trackWhileParked: prop.trackWhileParked ?? DOT_DEFAULTS.trackWhileParked, }; } diff --git a/packages/react-native-livechart/src/hooks/useLiveDot.ts b/packages/react-native-livechart/src/hooks/useLiveDot.ts index 1bb1867c..d8702b2e 100644 --- a/packages/react-native-livechart/src/hooks/useLiveDot.ts +++ b/packages/react-native-livechart/src/hooks/useLiveDot.ts @@ -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"; /** @@ -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, 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(() => { diff --git a/packages/react-native-livechart/src/types.ts b/packages/react-native-livechart/src/types.ts index 4e1b5306..31c70f3a 100644 --- a/packages/react-native-livechart/src/types.ts +++ b/packages/react-native-livechart/src/types.ts @@ -1542,6 +1542,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}). */ diff --git a/packages/react-native-livechart/tests/LiveChart.test.tsx b/packages/react-native-livechart/tests/LiveChart.test.tsx index 40766e7a..d3323ff9 100644 --- a/packages/react-native-livechart/tests/LiveChart.test.tsx +++ b/packages/react-native-livechart/tests/LiveChart.test.tsx @@ -235,6 +235,24 @@ describe("LiveChart", () => { await render(); }); + it("renders with dot.trackWhileParked (on, off, and followViewEdge precedence)", async () => { + const screen = await render( + , + ); + await screen.rerender( + , + ); + // `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( + , + ); + }); + it("renders with axisAutoHide enabled (defaults and config object)", async () => { const screen = await render(); await screen.rerender( diff --git a/packages/react-native-livechart/tests/components/overlays.test.tsx b/packages/react-native-livechart/tests/components/overlays.test.tsx index e4128337..21a94ba7 100644 --- a/packages/react-native-livechart/tests/components/overlays.test.tsx +++ b/packages/react-native-livechart/tests/components/overlays.test.tsx @@ -254,6 +254,28 @@ describe("DotOverlay", () => { await render(); }); + 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(900); // scrolled back + return ( + + ); + } + await render(); + }); + it("renders with pulse disabled", async () => { function Fixture() { const dotX = useSharedValue(100); diff --git a/packages/react-native-livechart/tests/hooks/useLiveDot.test.tsx b/packages/react-native-livechart/tests/hooks/useLiveDot.test.tsx index 07b9a26c..06510ea6 100644 --- a/packages/react-native-livechart/tests/hooks/useLiveDot.test.tsx +++ b/packages/react-native-livechart/tests/hooks/useLiveDot.test.tsx @@ -1,5 +1,9 @@ import { DEFAULT_PADDING } from "../../src/draw/line"; -import type { SingleEngineState } from "../../src/core/useLiveChartEngine"; +import type { + ChartEngineScroll, + SingleEngineState, +} from "../../src/core/useLiveChartEngine"; +import type { LiveChartPoint } from "../../src/types"; import { renderHook } from "@testing-library/react-native"; import type { SharedValue } from "react-native-reanimated"; import { useLiveDot } from "../../src/hooks/useLiveDot"; @@ -11,19 +15,24 @@ function engine( displayMin: number; displayMax: number; displayValue: number; + displayWindow: number; + timestamp: number; + data: LiveChartPoint[]; + viewEnd: number | null; }>, -): SingleEngineState { +): SingleEngineState & ChartEngineScroll { return { - data: { value: [] }, + data: { value: partial.data ?? [] }, value: { value: 0 }, displayValue: { value: partial.displayValue ?? 5 }, displayMin: { value: partial.displayMin ?? 0 }, displayMax: { value: partial.displayMax ?? 10 }, - displayWindow: { value: 30 }, + displayWindow: { value: partial.displayWindow ?? 30 }, canvasWidth: { value: partial.canvasWidth ?? 200 }, canvasHeight: { value: partial.canvasHeight ?? 100 }, - timestamp: { value: 0 }, - } as unknown as SingleEngineState; + timestamp: { value: partial.timestamp ?? 0 }, + viewEnd: { value: partial.viewEnd ?? null }, + } as unknown as SingleEngineState & ChartEngineScroll; } describe("useLiveDot", () => { @@ -70,4 +79,135 @@ describe("useLiveDot", () => { // value (5) which would sit mid-range. chartH = 100-12-28 = 60. expect(result.current.dotY.value).toBeCloseTo(12 + ((10 - 9) / 10) * 60); }); + + describe("trackWhileParked", () => { + // Frozen window: [970, 1000] (timestamp 1000, displayWindow 30). + // chartW = 200 - 12 - 12 = 176; plot right edge = 188. + const parked = { + timestamp: 1000, + displayWindow: 30, + viewEnd: 985, + }; + + it("stays pinned to the right edge while parked when off (default)", async () => { + const { result } = await renderHook(() => + useLiveDot( + engine({ ...parked, data: [{ time: 985, value: 5 }] }), + DEFAULT_PADDING, + ), + ); + expect(result.current.dotX.value).toBe(188); + }); + + it("tracks the live point's x while parked", async () => { + const { result } = await renderHook(() => + useLiveDot( + engine({ ...parked, data: [{ time: 985, value: 5 }] }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + // x = left + ((985 - 970) / 30) * 176 = 12 + 88 + expect(result.current.dotX.value).toBeCloseTo(100); + }); + + it("hides the dot once the live point leaves the window (both sides)", async () => { + const past = await renderHook(() => + useLiveDot( + engine({ ...parked, data: [{ time: 960, value: 5 }] }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + expect(past.result.current.dotX.value).toBe(-100); + + const future = await renderHook(() => + useLiveDot( + engine({ ...parked, data: [{ time: 1005, value: 5 }] }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + expect(future.result.current.dotX.value).toBe(-100); + }); + + it("hides the dot while parked with no data", async () => { + const { result } = await renderHook(() => + useLiveDot( + engine({ ...parked, data: [] }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + expect(result.current.dotX.value).toBe(-100); + }); + + it("hides the dot while parked with a degenerate window or plot width", async () => { + const zeroWindow = await renderHook(() => + useLiveDot( + engine({ + ...parked, + displayWindow: 0, + data: [{ time: 985, value: 5 }], + }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + expect(zeroWindow.result.current.dotX.value).toBe(-100); + + // Canvas narrower than the horizontal padding → non-positive plot width. + const noPlot = await renderHook(() => + useLiveDot( + engine({ + ...parked, + canvasWidth: 20, + data: [{ time: 985, value: 5 }], + }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + expect(noPlot.result.current.dotX.value).toBe(-100); + }); + + it("keeps the right-edge pin while following live (viewEnd null)", async () => { + const { result } = await renderHook(() => + useLiveDot( + engine({ timestamp: 1000, data: [{ time: 985, value: 5 }] }), + DEFAULT_PADDING, + undefined, + false, + true, + ), + ); + expect(result.current.dotX.value).toBe(188); + }); + + it("is ignored with followViewEdge (edge-pinned dot stays with its badge)", async () => { + const edgeValue = { value: 9 } as unknown as SharedValue; + const { result } = await renderHook(() => + useLiveDot( + engine({ ...parked, data: [{ time: 985, value: 5 }] }), + DEFAULT_PADDING, + edgeValue, + true, + true, + ), + ); + expect(result.current.dotX.value).toBe(188); + }); + }); }); diff --git a/packages/react-native-livechart/tests/resolveConfig.test.ts b/packages/react-native-livechart/tests/resolveConfig.test.ts index 70c1b68d..1a4cbd3a 100644 --- a/packages/react-native-livechart/tests/resolveConfig.test.ts +++ b/packages/react-native-livechart/tests/resolveConfig.test.ts @@ -1411,13 +1411,20 @@ describe("resolveDot", () => { ring: { color: undefined, width: 2.5 }, show: true, color: undefined, + trackWhileParked: false, }); }); it("applies overrides (flat ring, hidden, color, radius)", () => { expect( resolveDot({ radius: 6, ring: false, show: false, color: "#abcdef" }), - ).toEqual({ radius: 6, ring: null, show: false, color: "#abcdef" }); + ).toEqual({ + radius: 6, + ring: null, + show: false, + color: "#abcdef", + trackWhileParked: false, + }); }); it("treats `true` as shown defaults", () => { @@ -1431,8 +1438,20 @@ describe("resolveDot", () => { ring: { color: undefined, width: 2.5 }, show: false, color: undefined, + trackWhileParked: false, }); }); + + it("defaults trackWhileParked to off when omitted from the config object", () => { + expect(resolveDot({ radius: 6 }).trackWhileParked).toBe(false); + }); + + it("passes an explicit trackWhileParked through", () => { + expect(resolveDot({ trackWhileParked: true }).trackWhileParked).toBe(true); + expect(resolveDot({ trackWhileParked: false }).trackWhileParked).toBe( + false, + ); + }); }); // ─── resolveMultiSeriesDot ────────────────────────────────────────────────────── @@ -1444,6 +1463,7 @@ describe("resolveMultiSeriesDot", () => { ring: { color: undefined, width: 2.5 }, show: true, color: undefined, + trackWhileParked: false, pulse: expect.objectContaining({ maxRadius: expect.any(Number) }), valueLine: null, valueLabel: true, @@ -1464,6 +1484,7 @@ describe("resolveMultiSeriesDot", () => { ring: null, show: false, color: "#abcdef", + trackWhileParked: false, pulse: expect.objectContaining({ maxRadius: expect.any(Number) }), valueLine: null, valueLabel: false,