diff --git a/dev/main.tsx b/dev/main.tsx index 7b1604d..d90e030 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -151,7 +151,7 @@ function Demo() { return (
+ + {/* Synced charts demo */} +
) } @@ -548,6 +551,109 @@ function MultiSeriesDemo({ theme }: { theme: 'dark' | 'light' }) { ) } +// ─── Synced Charts Demo ────────────────────────────────────── + +function SyncedChartsDemo({ theme }: { theme: 'dark' | 'light' }) { + const POINT_COUNT = 200 + const WINDOW_SECS = 60 + + // Three deterministic datasets sharing the same time axis + const datasets = React.useMemo(() => { + const now = Date.now() / 1000 + 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 })), + } + }, []) + + const [syncTime, setSyncTime] = useState(null) + + const chartStyle: React.CSSProperties = { + height: 180, + background: 'var(--fg-02)', + borderRadius: 12, + border: '1px solid var(--fg-06)', + padding: 8, + overflow: 'hidden', + } + + return ( + <> +

Synced Charts (activeTime)

+

+ Hover on any chart to sync crosshairs across all three +

+ +
+
+
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)} + /> +
+
+
+ + ) +} + // --- UI components --- function Section({ label, children }: { label: string; children: React.ReactNode }) { diff --git a/src/Liveline.tsx b/src/Liveline.tsx index 9c22eee..bf7a0d4 100644 --- a/src/Liveline.tsx +++ b/src/Liveline.tsx @@ -39,6 +39,7 @@ export function Liveline({ windowStyle, tooltipY = 14, tooltipOutline = true, + activeTime, orderbook, referenceLine, formatValue = defaultFormatValue, @@ -211,6 +212,7 @@ export function Liveline({ loading, paused, emptyText, + activeTime, mode, candles, candleWidth, diff --git a/src/draw/index.ts b/src/draw/index.ts index dd4aefd..9d96014 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 + activeTimeDraw?: { 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 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) @@ -224,6 +226,19 @@ export function drawFrame( } } + // 8b. Programmatic activeTime crosshair — full opacity, no live-dot clamp + if (opts.activeTimeDraw) { + drawCrosshair( + ctx, layout, palette, + opts.activeTimeDraw.x, opts.activeTimeDraw.value, opts.activeTimeDraw.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() diff --git a/src/types.ts b/src/types.ts index 344d693..aa82cc6 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 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 7c29276..9dfa3a3 100644 --- a/src/useLivelineEngine.ts +++ b/src/useLivelineEngine.ts @@ -44,6 +44,7 @@ interface EngineConfig { loading?: boolean paused?: boolean emptyText?: string + activeTime?: number // Candlestick mode mode: 'line' | 'candle' @@ -1657,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) { @@ -1819,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) @@ -1828,6 +1854,23 @@ export function useLivelineEngine( : 0 const swingMagnitude = valRange > 0 ? Math.min(recentDelta / valRange, 1) : 0 + // 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, + } + } + } + } + // Draw canvas content (everything except badge) drawFrame(ctx, layout, cfg.palette, { visible, @@ -1862,6 +1905,7 @@ export function useLivelineEngine( chartReveal, pauseProgress, now_ms, + activeTimeDraw, }) // During morph (chart ↔ empty), overlay the gradient gap + text on