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