From e4d3dcb0a3f888f8b61bcbcca8777b9849ce68fc Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Tue, 24 Mar 2026 00:03:33 -0300 Subject: [PATCH 1/5] Add activePoint prop to LivelineProps --- src/Liveline.tsx | 2 ++ src/types.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Liveline.tsx b/src/Liveline.tsx index 9c22eee..0ff8cea 100644 --- a/src/Liveline.tsx +++ b/src/Liveline.tsx @@ -39,6 +39,7 @@ export function Liveline({ windowStyle, tooltipY = 14, tooltipOutline = true, + activePoint, orderbook, referenceLine, formatValue = defaultFormatValue, @@ -211,6 +212,7 @@ export function Liveline({ loading, paused, emptyText, + activePoint, mode, candles, candleWidth, diff --git a/src/types.ts b/src/types.ts index 344d693..c6c8241 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,6 +94,8 @@ export interface LivelineProps { // Crosshair tooltipY?: number // Vertical offset for crosshair tooltip text (default: 14) tooltipOutline?: boolean // Stroke outline around crosshair tooltip text for readability (default: true) + /** Programmatic crosshair at [time, value]. time in unix seconds. Only rendered when paused in single-series line mode. */ + activePoint?: [number, number] // Orderbook orderbook?: OrderbookData From 7288a87fb365c43037a9623e0a377b4d24c75997 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Tue, 24 Mar 2026 00:03:45 -0300 Subject: [PATCH 2/5] Draw programmatic crosshair, suppress hover when active --- src/draw/index.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/draw/index.ts b/src/draw/index.ts index dd4aefd..ea58290 100644 --- a/src/draw/index.ts +++ b/src/draw/index.ts @@ -60,6 +60,7 @@ export interface DrawOptions { chartReveal: number // 0 = loading/morphing from center, 1 = fully revealed pauseProgress: number // 0 = playing, 1 = fully paused now_ms: number // performance.now() for breathing animation timing + activePoint?: { x: number; y: number; value: number; time: number } } /** @@ -203,7 +204,8 @@ export function drawFrame( ctx.restore() // 8. Crosshair — fade out well before reaching live dot - if (opts.hoverX !== null && opts.hoverValue !== null && opts.hoverTime !== null && pts && pts.length > 0) { + // Suppressed when activePoint is present (programmatic crosshair takes priority) + if (!opts.activePoint && opts.hoverX !== null && opts.hoverValue !== null && opts.hoverTime !== null && pts && pts.length > 0) { const lastPt = pts[pts.length - 1] const distToLive = lastPt[0] - opts.hoverX const fadeStart = Math.min(80, layout.chartW * 0.3) @@ -224,6 +226,19 @@ export function drawFrame( } } + // 8b. Programmatic activePoint crosshair — full opacity, no live-dot clamp + if (opts.activePoint) { + drawCrosshair( + ctx, layout, palette, + opts.activePoint.x, opts.activePoint.value, opts.activePoint.time, + opts.formatValue, opts.formatTime, + 1, // full opacity + opts.tooltipY, + undefined, // no liveDotX — chart is paused + opts.tooltipOutline, + ) + } + // Restore shake translate if (shake && (shakeX !== 0 || shakeY !== 0)) { ctx.restore() From 4fde1422c4e41d696ec052e01171c5be4306096f Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Tue, 24 Mar 2026 00:03:57 -0300 Subject: [PATCH 3/5] Compute activePoint screen position from [time, value] tuple --- src/useLivelineEngine.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/useLivelineEngine.ts b/src/useLivelineEngine.ts index 7c29276..f2591ee 100644 --- a/src/useLivelineEngine.ts +++ b/src/useLivelineEngine.ts @@ -44,6 +44,7 @@ interface EngineConfig { loading?: boolean paused?: boolean emptyText?: string + activePoint?: [number, number] // Candlestick mode mode: 'line' | 'candle' @@ -1828,6 +1829,20 @@ export function useLivelineEngine( : 0 const swingMagnitude = valRange > 0 ? Math.min(recentDelta / valRange, 1) : 0 + // Programmatic activePoint — only when paused in single-series line mode + let activePointDraw: { x: number; y: number; value: number; time: number } | undefined + if (cfg.activePoint && cfg.paused && pauseProgress > 0.5 && !cfg.isMultiSeries) { + const [apTime, apValue] = cfg.activePoint + if (apTime >= leftEdge && apTime <= rightEdge && apValue >= minVal && apValue <= maxVal) { + activePointDraw = { + x: layout.toX(apTime), + y: layout.toY(apValue), + value: apValue, + time: apTime, + } + } + } + // Draw canvas content (everything except badge) drawFrame(ctx, layout, cfg.palette, { visible, @@ -1862,6 +1877,7 @@ export function useLivelineEngine( chartReveal, pauseProgress, now_ms, + activePoint: activePointDraw, }) // During morph (chart ↔ empty), overlay the gradient gap + text on From 55497308d075c9a3479ef054f6d02381270d63ac Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Tue, 24 Mar 2026 00:05:29 -0300 Subject: [PATCH 4/5] Add interactive activePoint demo with slider --- dev/main.tsx | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/dev/main.tsx b/dev/main.tsx index 7b1604d..e2f1c14 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -346,6 +346,9 @@ function Demo() { {/* Multi-series demo */} + + {/* Active point demo */} + ) } @@ -548,6 +551,77 @@ function MultiSeriesDemo({ theme }: { theme: 'dark' | 'light' }) { ) } +// ─── Active Point Demo ───────────────────────────────────────── + +function ActivePointDemo({ theme }: { theme: 'dark' | 'light' }) { + const POINT_COUNT = 200 + const WINDOW_SECS = 30 + + // Fixed sine dataset — deterministic, paused + const dataset = React.useMemo(() => { + const now = Date.now() / 1000 + const pts: LivelinePoint[] = [] + for (let i = 0; i < POINT_COUNT; i++) { + const t = now - (POINT_COUNT - 1 - i) * (WINDOW_SECS / POINT_COUNT) + pts.push({ time: t, value: 100 + Math.sin(i * 0.08) * 15 + Math.sin(i * 0.03) * 8 }) + } + return pts + }, []) + + const lastValue = dataset[dataset.length - 1].value + const [pointIndex, setPointIndex] = useState(Math.floor(POINT_COUNT * 0.5)) + + const point = dataset[pointIndex] + const activePoint: [number, number] = [point.time, point.value] + + return ( + <> +

Active Point

+

+ Programmatic crosshair on a paused chart +

+ +
+ setPointIndex(Number(e.target.value))} + style={{ width: 200 }} + /> + + idx {pointIndex} — value {point.value.toFixed(2)} + +
+ +
+ +
+ + ) +} + // --- UI components --- function Section({ label, children }: { label: string; children: React.ReactNode }) { From abc30153261e007baf35dbf58b7000e13b26f3f2 Mon Sep 17 00:00:00 2001 From: Leonardo Galante Date: Wed, 25 Mar 2026 00:58:30 -0300 Subject: [PATCH 5/5] Remove activePoint prop, keep activeTime as the sole programmatic crosshair API --- dev/main.tsx | 142 ++++++++++++++++++++++++--------------- src/Liveline.tsx | 4 +- src/draw/index.ts | 12 ++-- src/types.ts | 4 +- src/useLivelineEngine.ts | 54 +++++++++++---- 5 files changed, 138 insertions(+), 78 deletions(-) diff --git a/dev/main.tsx b/dev/main.tsx index e2f1c14..d90e030 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -151,7 +151,7 @@ function Demo() { return (
- {/* Active point demo */} - + {/* Synced charts demo */} +
) } @@ -551,72 +551,104 @@ function MultiSeriesDemo({ theme }: { theme: 'dark' | 'light' }) { ) } -// ─── Active Point Demo ───────────────────────────────────────── +// ─── Synced Charts Demo ────────────────────────────────────── -function ActivePointDemo({ theme }: { theme: 'dark' | 'light' }) { +function SyncedChartsDemo({ theme }: { theme: 'dark' | 'light' }) { const POINT_COUNT = 200 - const WINDOW_SECS = 30 + const WINDOW_SECS = 60 - // Fixed sine dataset — deterministic, paused - const dataset = React.useMemo(() => { + // Three deterministic datasets sharing the same time axis + const datasets = React.useMemo(() => { const now = Date.now() / 1000 - const pts: LivelinePoint[] = [] - for (let i = 0; i < POINT_COUNT; i++) { - const t = now - (POINT_COUNT - 1 - i) * (WINDOW_SECS / POINT_COUNT) - pts.push({ time: t, value: 100 + Math.sin(i * 0.08) * 15 + Math.sin(i * 0.03) * 8 }) + const times = Array.from({ length: POINT_COUNT }, (_, i) => + now - (POINT_COUNT - 1 - i) * (WINDOW_SECS / POINT_COUNT), + ) + return { + tvl: times.map((t, i) => ({ time: t, value: 500 + Math.sin(i * 0.05) * 200 + Math.cos(i * 0.02) * 100 })), + apy: times.map((t, i) => ({ time: t, value: 5 + Math.sin(i * 0.08) * 3 + Math.cos(i * 0.03) * 1.5 })), + price: times.map((t, i) => ({ time: t, value: 1.0 + Math.sin(i * 0.04) * 0.05 + i * 0.0002 })), } - return pts }, []) - const lastValue = dataset[dataset.length - 1].value - const [pointIndex, setPointIndex] = useState(Math.floor(POINT_COUNT * 0.5)) + const [syncTime, setSyncTime] = useState(null) - const point = dataset[pointIndex] - const activePoint: [number, number] = [point.time, point.value] + const chartStyle: React.CSSProperties = { + height: 180, + background: 'var(--fg-02)', + borderRadius: 12, + border: '1px solid var(--fg-06)', + padding: 8, + overflow: 'hidden', + } return ( <> -

Active Point

+

Synced Charts (activeTime)

- Programmatic crosshair on a paused chart + Hover on any chart to sync crosshairs across all three

-
- setPointIndex(Number(e.target.value))} - style={{ width: 200 }} - /> - - idx {pointIndex} — value {point.value.toFixed(2)} - -
- -
- +
+
+
TVL
+
+ setSyncTime(p?.time ?? null)} + activeTime={syncTime ?? undefined} + formatValue={v => `$${v.toFixed(0)}M`} + /> +
+
+
+
APY
+
+ setSyncTime(p?.time ?? null)} + activeTime={syncTime ?? undefined} + formatValue={v => `${v.toFixed(2)}%`} + /> +
+
+
+
Share Price
+
+ setSyncTime(p?.time ?? null)} + activeTime={syncTime ?? undefined} + formatValue={v => v.toFixed(4)} + /> +
+
) diff --git a/src/Liveline.tsx b/src/Liveline.tsx index 0ff8cea..bf7a0d4 100644 --- a/src/Liveline.tsx +++ b/src/Liveline.tsx @@ -39,7 +39,7 @@ export function Liveline({ windowStyle, tooltipY = 14, tooltipOutline = true, - activePoint, + activeTime, orderbook, referenceLine, formatValue = defaultFormatValue, @@ -212,7 +212,7 @@ export function Liveline({ loading, paused, emptyText, - activePoint, + activeTime, mode, candles, candleWidth, diff --git a/src/draw/index.ts b/src/draw/index.ts index ea58290..9d96014 100644 --- a/src/draw/index.ts +++ b/src/draw/index.ts @@ -60,7 +60,7 @@ export interface DrawOptions { chartReveal: number // 0 = loading/morphing from center, 1 = fully revealed pauseProgress: number // 0 = playing, 1 = fully paused now_ms: number // performance.now() for breathing animation timing - activePoint?: { x: number; y: number; value: number; time: number } + activeTimeDraw?: { x: number; y: number; value: number; time: number } } /** @@ -204,8 +204,8 @@ export function drawFrame( ctx.restore() // 8. Crosshair — fade out well before reaching live dot - // Suppressed when activePoint is present (programmatic crosshair takes priority) - if (!opts.activePoint && opts.hoverX !== null && opts.hoverValue !== null && opts.hoverTime !== null && pts && pts.length > 0) { + // Suppressed when activeTime crosshair is present (programmatic crosshair takes priority) + if (!opts.activeTimeDraw && opts.hoverX !== null && opts.hoverValue !== null && opts.hoverTime !== null && pts && pts.length > 0) { const lastPt = pts[pts.length - 1] const distToLive = lastPt[0] - opts.hoverX const fadeStart = Math.min(80, layout.chartW * 0.3) @@ -226,11 +226,11 @@ export function drawFrame( } } - // 8b. Programmatic activePoint crosshair — full opacity, no live-dot clamp - if (opts.activePoint) { + // 8b. Programmatic activeTime crosshair — full opacity, no live-dot clamp + if (opts.activeTimeDraw) { drawCrosshair( ctx, layout, palette, - opts.activePoint.x, opts.activePoint.value, opts.activePoint.time, + opts.activeTimeDraw.x, opts.activeTimeDraw.value, opts.activeTimeDraw.time, opts.formatValue, opts.formatTime, 1, // full opacity opts.tooltipY, diff --git a/src/types.ts b/src/types.ts index c6c8241..aa82cc6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,8 +94,8 @@ export interface LivelineProps { // Crosshair tooltipY?: number // Vertical offset for crosshair tooltip text (default: 14) tooltipOutline?: boolean // Stroke outline around crosshair tooltip text for readability (default: true) - /** Programmatic crosshair at [time, value]. time in unix seconds. Only rendered when paused in single-series line mode. */ - activePoint?: [number, number] + /** Programmatic crosshair at a time (unix seconds). The chart interpolates the Y value(s) internally. Works live or paused, single or multi-series. */ + activeTime?: number // Orderbook orderbook?: OrderbookData diff --git a/src/useLivelineEngine.ts b/src/useLivelineEngine.ts index f2591ee..9dfa3a3 100644 --- a/src/useLivelineEngine.ts +++ b/src/useLivelineEngine.ts @@ -44,7 +44,7 @@ interface EngineConfig { loading?: boolean paused?: boolean emptyText?: string - activePoint?: [number, number] + activeTime?: number // Candlestick mode mode: 'line' | 'candle' @@ -1658,6 +1658,31 @@ export function useLivelineEngine( cfg.onHover?.({ time: t, value: hoverEntries[0]?.value ?? 0, x: clampedX, y: layout.toY(hoverEntries[0]?.value ?? 0) }) } + // Programmatic activeTime — interpolate each series (no onHover callback) + if (cfg.activeTime != null && !isActiveHover) { + if (cfg.activeTime >= leftEdge && cfg.activeTime <= rightEdge) { + drawHoverX = layout.toX(cfg.activeTime) + drawHoverTime = cfg.activeTime + isActiveHover = true + + for (const entry of seriesEntries) { + if ((entry.alpha ?? 1) < 0.5) { + continue + } + + const v = interpolateAtTime(entry.visible, cfg.activeTime) + + if (v !== null) { + hoverEntries.push({ + color: entry.palette.line, + label: entry.label ?? '', + value: v + }) + } + } + } + } + // Scrub amount const scrubTarget = isActiveHover ? 1 : 0 if (noMotion) { @@ -1820,7 +1845,7 @@ export function useLivelineEngine( ) scrubAmountRef.current = hoverResult.scrubAmount lastHoverRef.current = hoverResult.lastHover - const { hoverX: drawHoverX, hoverValue: drawHoverValue, hoverTime: drawHoverTime } = hoverResult + const { hoverX: drawHoverX, hoverValue: drawHoverValue, hoverTime: drawHoverTime, isActiveHover } = hoverResult // Compute swing magnitude for particles (recent velocity / visible range) const lookback = Math.min(5, visible.length - 1) @@ -1829,16 +1854,19 @@ export function useLivelineEngine( : 0 const swingMagnitude = valRange > 0 ? Math.min(recentDelta / valRange, 1) : 0 - // Programmatic activePoint — only when paused in single-series line mode - let activePointDraw: { x: number; y: number; value: number; time: number } | undefined - if (cfg.activePoint && cfg.paused && pauseProgress > 0.5 && !cfg.isMultiSeries) { - const [apTime, apValue] = cfg.activePoint - if (apTime >= leftEdge && apTime <= rightEdge && apValue >= minVal && apValue <= maxVal) { - activePointDraw = { - x: layout.toX(apTime), - y: layout.toY(apValue), - value: apValue, - time: apTime, + // Programmatic activeTime — interpolate Y value from data (works live + paused) + // Skipped when user is actively hovering (user hover wins) + let activeTimeDraw: { x: number; y: number; value: number; time: number } | undefined + if (cfg.activeTime != null && !cfg.isMultiSeries && !isActiveHover) { + if (cfg.activeTime >= leftEdge && cfg.activeTime <= rightEdge) { + const value = interpolateAtTime(visible, cfg.activeTime) + if (value !== null) { + activeTimeDraw = { + x: layout.toX(cfg.activeTime), + y: layout.toY(value), + value, + time: cfg.activeTime, + } } } } @@ -1877,7 +1905,7 @@ export function useLivelineEngine( chartReveal, pauseProgress, now_ms, - activePoint: activePointDraw, + activeTimeDraw, }) // During morph (chart ↔ empty), overlay the gradient gap + text on