Skip to content
Open
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
108 changes: 107 additions & 1 deletion dev/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function Demo() {

return (
<div style={{
padding: 32, maxWidth: 960, margin: '0 auto',
padding: 32, maxWidth: 1200, margin: '0 auto',
color: isDark ? '#fff' : '#111',
background: pageBg,
minHeight: '100vh',
Expand Down Expand Up @@ -346,6 +346,9 @@ function Demo() {

{/* Multi-series demo */}
<MultiSeriesDemo theme={theme} />

{/* Synced charts demo */}
<SyncedChartsDemo theme={theme} />
</div>
)
}
Expand Down Expand Up @@ -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<number | null>(null)

const chartStyle: React.CSSProperties = {
height: 180,
background: 'var(--fg-02)',
borderRadius: 12,
border: '1px solid var(--fg-06)',
padding: 8,
overflow: 'hidden',
}

return (
<>
<h2 style={{ fontSize: 16, fontWeight: 600, marginTop: 40, marginBottom: 4, borderBottom: 'none' }}>Synced Charts (activeTime)</h2>
<p style={{ fontSize: 12, color: 'var(--fg-30)', marginBottom: 12 }}>
Hover on any chart to sync crosshairs across all three
</p>

<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 4, color: 'var(--fg-50)' }}>TVL</div>
<div style={chartStyle}>
<Liveline
data={datasets.tvl}
value={datasets.tvl[datasets.tvl.length - 1].value}
theme={theme}
window={WINDOW_SECS}
paused
grid
fill
badge={false}
momentum={false}
color="#3b82f6"
onHover={p => setSyncTime(p?.time ?? null)}
activeTime={syncTime ?? undefined}
formatValue={v => `$${v.toFixed(0)}M`}
/>
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 4, color: 'var(--fg-50)' }}>APY</div>
<div style={chartStyle}>
<Liveline
data={datasets.apy}
value={datasets.apy[datasets.apy.length - 1].value}
theme={theme}
window={WINDOW_SECS}
paused
grid
fill
badge={false}
momentum={false}
color="#f59e0b"
onHover={p => setSyncTime(p?.time ?? null)}
activeTime={syncTime ?? undefined}
formatValue={v => `${v.toFixed(2)}%`}
/>
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, marginBottom: 4, color: 'var(--fg-50)' }}>Share Price</div>
<div style={chartStyle}>
<Liveline
data={datasets.price}
value={datasets.price[datasets.price.length - 1].value}
theme={theme}
window={WINDOW_SECS}
paused
grid
fill
badge={false}
momentum={false}
color="#22c55e"
onHover={p => setSyncTime(p?.time ?? null)}
activeTime={syncTime ?? undefined}
formatValue={v => v.toFixed(4)}
/>
</div>
</div>
</div>
</>
)
}

// --- UI components ---

function Section({ label, children }: { label: string; children: React.ReactNode }) {
Expand Down
2 changes: 2 additions & 0 deletions src/Liveline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function Liveline({
windowStyle,
tooltipY = 14,
tooltipOutline = true,
activeTime,
orderbook,
referenceLine,
formatValue = defaultFormatValue,
Expand Down Expand Up @@ -211,6 +212,7 @@ export function Liveline({
loading,
paused,
emptyText,
activeTime,
mode,
candles,
candleWidth,
Expand Down
17 changes: 16 additions & 1 deletion src/draw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

/**
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion src/useLivelineEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface EngineConfig {
loading?: boolean
paused?: boolean
emptyText?: string
activeTime?: number

// Candlestick mode
mode: 'line' | 'candle'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -1862,6 +1905,7 @@ export function useLivelineEngine(
chartReveal,
pauseProgress,
now_ms,
activeTimeDraw,
})

// During morph (chart ↔ empty), overlay the gradient gap + text on
Expand Down