From be39952598c7896862b8b3766761ad7dd4ee98ad Mon Sep 17 00:00:00 2001 From: 0xChandi <183944703+0xChandi@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:45:41 -0500 Subject: [PATCH 1/4] Add bar chart layer with default and overlay modes Bar chart renders time-bucketed data (e.g. volume) alongside the existing line/candlestick chart. Two modes: - default: bars in a separate strip below the main chart (20% height) - overlay: semi-transparent bars behind the line Features: - Smooth Y-range scaling using the same lerp pattern as the line chart - Scrub dimming, reveal animations, and choreographed entrance - Optional value labels (barLabels prop) - Custom bar color override (barColor prop) - Works with both line and candlestick chart modes New props: bars, barMode, barWidth, barColor, barLabels New types: BarPoint, BarMode Co-Authored-By: Claude Opus 4.6 --- dev/demo.tsx | 115 +++++++++++++++++++++++++++++-- src/Liveline.tsx | 10 +++ src/draw/bars.ts | 120 +++++++++++++++++++++++++++++++++ src/draw/index.ts | 103 +++++++++++++++++++++++++++- src/index.ts | 2 + src/theme.ts | 4 ++ src/types.ts | 18 +++++ src/useLivelineEngine.ts | 141 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 503 insertions(+), 10 deletions(-) create mode 100644 src/draw/bars.ts diff --git a/dev/demo.tsx b/dev/demo.tsx index b9a51e2..1d006a5 100644 --- a/dev/demo.tsx +++ b/dev/demo.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' import { createRoot } from 'react-dom/client' import { Liveline } from 'liveline' -import type { LivelinePoint, CandlePoint } from 'liveline' +import type { LivelinePoint, CandlePoint, BarPoint } from 'liveline' // --- Data generators --- @@ -40,6 +40,42 @@ function aggregateCandles(ticks: LivelinePoint[], width: number): { candles: Can return { candles, live: { time: slot, open: o, high: h, low: l, close: c } } } +/** Generate initial volume bars from seed data */ +function generateBars(data: LivelinePoint[], bucketSecs: number): BarPoint[] { + if (data.length === 0) return [] + const bars: BarPoint[] = [] + const startTime = Math.floor(data[0].time / bucketSecs) * bucketSecs + let bucketStart = startTime + let bucketVolume = 0 + let prevValue = data[0].value + for (const pt of data) { + while (pt.time >= bucketStart + bucketSecs) { + if (bucketVolume > 0) bars.push({ time: bucketStart, value: bucketVolume }) + bucketStart += bucketSecs + bucketVolume = 0 + } + const change = Math.abs(pt.value - prevValue) + bucketVolume += 10 + change * 20 + Math.random() * 5 + prevValue = pt.value + } + if (bucketVolume > 0) bars.push({ time: bucketStart, value: bucketVolume }) + return bars +} + +/** Incrementally update bars with a new data point — only touches the last bucket */ +function addTickToBars(bars: BarPoint[], pt: LivelinePoint, prevValue: number, bucketSecs: number): BarPoint[] { + const bucketStart = Math.floor(pt.time / bucketSecs) * bucketSecs + const change = Math.abs(pt.value - prevValue) + const volume = 10 + change * 20 + Math.random() * 5 + if (bars.length === 0 || bars[bars.length - 1].time < bucketStart) { + return [...bars, { time: bucketStart, value: volume }] + } + const updated = bars.slice() + const last = updated[updated.length - 1] + updated[updated.length - 1] = { time: last.time, value: last.value + volume } + return updated +} + // --- Constants --- const TIME_WINDOWS = [ @@ -92,6 +128,9 @@ function Demo() { const [volatility, setVolatility] = useState('normal') const [tickRate, setTickRate] = useState(300) + const [bars, setBars] = useState([]) + const [barLabels, setBarLabels] = useState(false) + const barBucketSecs = 2 const [chartType, setChartType] = useState<'line' | 'candle'>('candle') const [candleSecs, setCandleSecs] = useState(2) @@ -162,10 +201,12 @@ function Demo() { setCandles(agg.candles) setLiveCandle(agg.live) liveCandleRef.current = agg.live ? { ...agg.live } : null + setBars(generateBars(seed, barBucketSecs)) intervalRef.current = window.setInterval(() => { const now = Date.now() / 1000 - const pt = generatePoint(lastValueRef.current, now, volatilityRef.current, startValueRef.current) + const prevVal = lastValueRef.current + const pt = generatePoint(prevVal, now, volatilityRef.current, startValueRef.current) lastValueRef.current = pt.value setValue(pt.value) setData(prev => { @@ -175,6 +216,7 @@ function Demo() { return trimmed }) tickAndAggregate(pt) + setBars(prev => addTickToBars(prev, pt, prevVal, barBucketSecs)) }, tickRate) }, [tickRate]) @@ -182,7 +224,7 @@ function Demo() { if (scenario === 'loading') { setLoading(true) setData([]); dataRef.current = [] - setCandles([]); setLiveCandle(null); liveCandleRef.current = null + setCandles([]); setLiveCandle(null); liveCandleRef.current = null; setBars([]) clearInterval(intervalRef.current) const timer = setTimeout(() => setScenario('live'), 3000) return () => clearTimeout(timer) @@ -191,7 +233,7 @@ function Demo() { if (scenario === 'loading-hold') { setLoading(true) setData([]); dataRef.current = [] - setCandles([]); setLiveCandle(null); liveCandleRef.current = null + setCandles([]); setLiveCandle(null); liveCandleRef.current = null; setBars([]) clearInterval(intervalRef.current) return } @@ -199,7 +241,7 @@ function Demo() { if (scenario === 'empty') { setLoading(false) setData([]); dataRef.current = [] - setCandles([]); setLiveCandle(null); liveCandleRef.current = null + setCandles([]); setLiveCandle(null); liveCandleRef.current = null; setBars([]) clearInterval(intervalRef.current) return } @@ -213,7 +255,8 @@ function Demo() { clearInterval(intervalRef.current) intervalRef.current = window.setInterval(() => { const now = Date.now() / 1000 - const pt = generatePoint(lastValueRef.current, now, volatilityRef.current, startValueRef.current) + const prevVal = lastValueRef.current + const pt = generatePoint(prevVal, now, volatilityRef.current, startValueRef.current) lastValueRef.current = pt.value setValue(pt.value) setData(prev => { @@ -223,6 +266,7 @@ function Demo() { return trimmed }) tickAndAggregate(pt) + setBars(prev => addTickToBars(prev, pt, prevVal, barBucketSecs)) }, tickRate) return () => clearInterval(intervalRef.current) }, [tickRate, scenario]) @@ -260,7 +304,7 @@ function Demo() { } // Force re-seed by cycling to loading setData([]); dataRef.current = [] - setCandles([]); setLiveCandle(null); liveCandleRef.current = null + setCandles([]); setLiveCandle(null); liveCandleRef.current = null; setBars([]) lastValueRef.current = preset === 'crypto' ? 65000 : 100 clearInterval(intervalRef.current) setLoading(true) @@ -348,6 +392,7 @@ function Demo() { Grid Scrub + Bar labels {/* Main chart */} @@ -428,6 +473,62 @@ function Demo() { ))} + {/* Bar chart — default mode */} +

Volume bars (default)

+
+ +
+ + {/* Bar chart — overlay mode */} +

Volume bars (overlay)

+
+ +
+ {/* Status bar */}
6 ? 1.5 : 0 + const baseAlpha = ctx.globalAlpha + + ctx.fillStyle = fillColor + + for (let i = 0; i < bars.length; i++) { + const b = bars[i] + if (b.value <= 0) continue + + const centerX = layout.toX(b.time + barWidthSecs / 2) + const x = centerX - halfBar + + // Skip bars fully outside visible area + if (x + barPxWidth < pad.left || x > pad.left + chartW) continue + + const rawHeight = (b.value / barLayout.maxValue) * barLayout.maxHeight + // During reveal, bars grow from zero height + const barH = rawHeight * revealProgress + if (barH < 0.5) continue + + const barY = barLayout.bottom - barH + + // Scrub dimming: bars to the right of scrubX are dimmed + if (scrubX !== null && scrubAmount > 0.05) { + const barCenter = centerX + if (barCenter > scrubX) { + ctx.globalAlpha = baseAlpha * (1 - scrubAmount * 0.6) + } else { + ctx.globalAlpha = baseAlpha + } + } + + if (cornerR > 0 && barH > cornerR * 2) { + // Rounded top corners only + ctx.beginPath() + ctx.moveTo(x, barLayout.bottom) + ctx.lineTo(x, barY + cornerR) + ctx.arcTo(x, barY, x + cornerR, barY, cornerR) + ctx.lineTo(x + barPxWidth - cornerR, barY) + ctx.arcTo(x + barPxWidth, barY, x + barPxWidth, barY + cornerR, cornerR) + ctx.lineTo(x + barPxWidth, barLayout.bottom) + ctx.closePath() + ctx.fill() + } else { + ctx.fillRect(x, barY, barPxWidth, barH) + } + } + + // Labels (optional) — drawn inside the bar, near the top + if (opts.showLabels && revealProgress > 0.5) { + ctx.save() + const fontSize = layout.w > 500 ? 9 : 8 + ctx.font = `${fontSize}px "SF Mono", Menlo, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + ctx.fillStyle = opts.labelColor + ctx.globalAlpha = baseAlpha * Math.min(1, (revealProgress - 0.5) * 4) + + for (let i = 0; i < bars.length; i++) { + const b = bars[i] + if (b.value <= 0) continue + const centerX = layout.toX(b.time + barWidthSecs / 2) + if (centerX < pad.left || centerX > pad.left + chartW) continue + const barH = (b.value / barLayout.maxValue) * barLayout.maxHeight * revealProgress + if (barH < fontSize + 6) continue // skip labels on bars too short to fit text + ctx.fillText(formatBarValue(b.value), centerX, barLayout.bottom - 3) + } + ctx.restore() + } + + ctx.globalAlpha = baseAlpha +} + +function formatBarValue(v: number): string { + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M` + if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K` + if (v >= 10) return Math.round(v).toString() + if (v >= 1) return v.toFixed(1) + return v.toFixed(2) +} diff --git a/src/draw/index.ts b/src/draw/index.ts index 6380334..de2597e 100644 --- a/src/draw/index.ts +++ b/src/draw/index.ts @@ -1,4 +1,4 @@ -import type { LivelinePalette, ChartLayout, LivelinePoint, Momentum, ReferenceLine, OrderbookData, DegenOptions, CandlePoint } from '../types' +import type { LivelinePalette, ChartLayout, LivelinePoint, Momentum, ReferenceLine, OrderbookData, DegenOptions, CandlePoint, BarPoint } from '../types' import { drawGrid, type GridState } from './grid' import { drawLine } from './line' import { drawDot, drawArrows } from './dot' @@ -9,6 +9,7 @@ import { drawOrderbook, type OrderbookState } from './orderbook' import { drawParticles, spawnOnSwing, type ParticleState } from './particles' import { drawCandlesticks, drawClosePrice, drawCandleCrosshair, drawLineModeCrosshair } from './candlestick' import { drawEmpty } from './empty' +import { drawBars, type BarLayout } from './bars' // Constants const SHAKE_DECAY_RATE = 0.002 @@ -59,6 +60,13 @@ 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 + // Bar chart + bars?: BarPoint[] + barWidthSecs?: number + barMode?: 'default' | 'overlay' + barFillColor?: string + barLayout?: BarLayout + barShowLabels?: boolean } /** @@ -124,6 +132,26 @@ export function drawFrame( ctx.restore() } + // 2c. Bars — overlay mode (behind line) + if (opts.bars && opts.barLayout && opts.barWidthSecs && opts.barMode === 'overlay' && opts.barFillColor) { + const barAlpha = reveal < 1 ? revealRamp(0.1, 0.6) : 1 + if (barAlpha > 0.01) { + ctx.save() + if (barAlpha < 1) ctx.globalAlpha = barAlpha + drawBars(ctx, layout, opts.barLayout, { + bars: opts.bars, + barWidthSecs: opts.barWidthSecs, + fillColor: opts.barFillColor, + labelColor: palette.gridLabel, + scrubX: opts.scrubAmount > 0.05 ? opts.hoverX : null, + scrubAmount: opts.scrubAmount, + revealProgress: reveal < 1 ? revealRamp(0.1, 0.8) : 1, + showLabels: opts.barShowLabels ?? false, + }) + ctx.restore() + } + } + // 3. Line + fill (with scrub dimming + reveal morphing) const scrubX = opts.scrubAmount > 0.05 ? opts.hoverX : null const pts = drawLine(ctx, layout, palette, opts.visible, opts.smoothValue, opts.now, opts.showFill, scrubX, opts.scrubAmount, reveal, opts.now_ms) @@ -190,6 +218,29 @@ export function drawFrame( } } + // 6b. Bars — default mode (separate bottom strip) + if (opts.bars && opts.barLayout && opts.barWidthSecs && opts.barMode === 'default' && opts.barFillColor) { + const barAlpha = reveal < 1 ? revealRamp(0.15, 0.7) : 1 + if (barAlpha > 0.01) { + ctx.save() + ctx.beginPath() + ctx.rect(layout.pad.left, opts.barLayout.bottom - opts.barLayout.maxHeight, layout.chartW, opts.barLayout.maxHeight) + ctx.clip() + if (barAlpha < 1) ctx.globalAlpha = barAlpha + drawBars(ctx, layout, opts.barLayout, { + bars: opts.bars, + barWidthSecs: opts.barWidthSecs, + fillColor: opts.barFillColor, + labelColor: palette.gridLabel, + scrubX: opts.scrubAmount > 0.05 ? opts.hoverX : null, + scrubAmount: opts.scrubAmount, + revealProgress: reveal < 1 ? revealRamp(0.1, 0.8) : 1, + showLabels: opts.barShowLabels ?? false, + }) + ctx.restore() + } + } + // 7. Left edge fade — gradient erase const fadeW = FADE_EDGE_WIDTH ctx.save() @@ -268,6 +319,13 @@ export interface CandleDrawOptions { emptyText?: string loadingAlpha: number showEmptyOverlay: boolean // true only when collapsing to empty (not loading, not forward morph) + // Bar chart + bars?: BarPoint[] + barWidthSecs?: number + barMode?: 'default' | 'overlay' + barFillColor?: string + barLayout?: BarLayout + barShowLabels?: boolean } /** @@ -315,6 +373,26 @@ export function drawCandleFrame( ctx.restore() } + // 1b. Bars — overlay mode (behind line + candles) + if (opts.bars && opts.barLayout && opts.barWidthSecs && opts.barMode === 'overlay' && opts.barFillColor) { + const barAlpha = reveal < 1 ? revealRamp(0.1, 0.6) : 1 + if (barAlpha > 0.01) { + ctx.save() + if (barAlpha < 1) ctx.globalAlpha = barAlpha + drawBars(ctx, layout, opts.barLayout, { + bars: opts.bars, + barWidthSecs: opts.barWidthSecs, + fillColor: opts.barFillColor, + labelColor: palette.gridLabel, + scrubX: opts.scrubAmount > 0.05 ? opts.hoverX : null, + scrubAmount: opts.scrubAmount, + revealProgress: reveal < 1 ? revealRamp(0.1, 0.8) : 1, + showLabels: opts.barShowLabels ?? false, + }) + ctx.restore() + } + } + // 2. Line — morph line that transforms from loading squiggly into data. // Returns pts for dot position. let linePts: [number, number][] | undefined @@ -418,6 +496,29 @@ export function drawCandleFrame( ctx.restore() } + // 4b. Bars — default mode (separate bottom strip) + if (opts.bars && opts.barLayout && opts.barWidthSecs && opts.barMode === 'default' && opts.barFillColor) { + const barAlpha = reveal < 1 ? revealRamp(0.15, 0.7) : 1 + if (barAlpha > 0.01) { + ctx.save() + ctx.beginPath() + ctx.rect(pad.left, opts.barLayout.bottom - opts.barLayout.maxHeight, chartW, opts.barLayout.maxHeight) + ctx.clip() + if (barAlpha < 1) ctx.globalAlpha = barAlpha + drawBars(ctx, layout, opts.barLayout, { + bars: opts.bars, + barWidthSecs: opts.barWidthSecs, + fillColor: opts.barFillColor, + labelColor: palette.gridLabel, + scrubX: opts.scrubAmount > 0.05 ? opts.hoverX : null, + scrubAmount: opts.scrubAmount, + revealProgress: reveal < 1 ? revealRamp(0.1, 0.8) : 1, + showLabels: opts.barShowLabels ?? false, + }) + ctx.restore() + } + } + // 5. Live dot — position from drawLine's returned pts (same as drawFrame). if (lp > 0.5 && linePts && linePts.length > 0 && reveal > 0.3) { const lastPt = linePts[linePts.length - 1] diff --git a/src/index.ts b/src/index.ts index 49d6a63..ba7e057 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,4 +15,6 @@ export type { DegenOptions, WindowStyle, BadgeVariant, + BarPoint, + BarMode, } from './types' diff --git a/src/theme.ts b/src/theme.ts index f02a43e..7908050 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -71,6 +71,10 @@ export function resolveTheme(color: string, mode: ThemeMode): LivelinePalette { // Background bgRgb: isDark ? [10, 10, 10] as [number, number, number] : [255, 255, 255] as [number, number, number], + // Bars + barFill: rgba(r, g, b, isDark ? 0.3 : 0.2), + barFillOverlay: rgba(r, g, b, isDark ? 0.08 : 0.06), + // Fonts labelFont: '11px "SF Mono", Menlo, Monaco, "Cascadia Code", monospace', valueFont: '600 11px "SF Mono", Menlo, monospace', diff --git a/src/types.ts b/src/types.ts index 1e74df0..a6970e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,6 +107,13 @@ export interface LivelineProps { lineValue?: number // Current tick value for density transition onModeChange?: (mode: 'line' | 'candle') => void // Built-in toggle callback + // Bar chart + bars?: BarPoint[] // Bar data (e.g., volume) + barMode?: BarMode // 'default' (bottom strip) or 'overlay' (behind line) — default: 'default' + barWidth?: number // Seconds per bar bucket + barColor?: string // Override accent-derived bar color + barLabels?: boolean // Show value labels on bars (default: false) + className?: string style?: CSSProperties } @@ -119,6 +126,13 @@ export interface CandlePoint { close: number } +export interface BarPoint { + time: number // unix seconds — bar period start + value: number // bar height value (e.g., volume) +} + +export type BarMode = 'default' | 'overlay' + export interface LivelinePalette { // Line line: string @@ -165,6 +179,10 @@ export interface LivelinePalette { // Background (for color fading — labels fade toward bg instead of alpha) bgRgb: [number, number, number] + // Bars + barFill: string + barFillOverlay: string + // Fonts labelFont: string valueFont: string diff --git a/src/useLivelineEngine.ts b/src/useLivelineEngine.ts index f682df4..e7202f4 100644 --- a/src/useLivelineEngine.ts +++ b/src/useLivelineEngine.ts @@ -1,5 +1,5 @@ import { useRef, useEffect, useCallback } from 'react' -import type { LivelinePoint, LivelinePalette, Momentum, ReferenceLine, HoverPoint, Padding, ChartLayout, OrderbookData, DegenOptions, BadgeVariant, CandlePoint } from './types' +import type { LivelinePoint, LivelinePalette, Momentum, ReferenceLine, HoverPoint, Padding, ChartLayout, OrderbookData, DegenOptions, BadgeVariant, CandlePoint, BarPoint, BarMode } from './types' import { lerp } from './math/lerp' import { computeRange } from './math/range' import { detectMomentum } from './math/momentum' @@ -52,6 +52,13 @@ interface EngineConfig { lineMode?: boolean lineData?: LivelinePoint[] lineValue?: number + + // Bar chart + bars?: BarPoint[] + barMode?: BarMode + barColor?: string + barWidthSecs?: number + barLabels?: boolean } interface BadgeEls { @@ -85,6 +92,10 @@ const PAUSE_CATCHUP_SPEED = 0.08 const PAUSE_CATCHUP_SPEED_FAST = 0.22 const LOADING_ALPHA_SPEED = 0.14 +// --- Bar chart constants --- +const BAR_STRIP_RATIO = 0.2 // default mode: 20% of chartH for bar strip +const BAR_OVERLAY_MAX_RATIO = 0.35 // overlay mode: max 35% of chartH for tallest bar + // --- Candle-specific constants --- const CANDLE_LERP_SPEED = 0.25 const CANDLE_WIDTH_TRANS_MS = 300 @@ -569,6 +580,9 @@ export function useLivelineEngine( const orderbookStateRef = useRef(createOrderbookState()) const particleStateRef = useRef(createParticleState()) const shakeStateRef = useRef(createShakeState()) + const displayBarMaxRef = useRef(0) + const targetBarMaxRef = useRef(0) + const barMaxInitedRef = useRef(false) const badgeColorRef = useRef({ green: 1 }) const badgeYRef = useRef(null) // lerped badge Y, null = uninited const reducedMotionRef = useRef(false) @@ -831,7 +845,11 @@ export function useLivelineEngine( const effectiveCandles = isCandle ? (pausedCandlesRef.current ?? (cfg.candles ?? [])) : ([] as CandlePoint[]) const hasData = isCandle ? effectiveCandles.length >= 2 : points.length >= 2 const pad = cfg.padding - const chartH = h - pad.top - pad.bottom + const fullChartH = h - pad.top - pad.bottom + const hasBars = cfg.bars && cfg.bars.length > 0 && cfg.barWidthSecs + const barIsDefault = (cfg.barMode ?? 'default') === 'default' + const barStripH = hasBars && barIsDefault ? Math.round(fullChartH * BAR_STRIP_RATIO) : 0 + const chartH = fullChartH - barStripH // --- Pause time management --- const pauseTarget = cfg.paused ? 1 : 0 @@ -1326,6 +1344,58 @@ export function useLivelineEngine( } } + // --- Bar chart computation (candle mode) --- + let candleBarDrawOpts: { + bars: BarPoint[]; barWidthSecs: number; barMode: BarMode + barFillColor: string; barLayout: { bottom: number; maxHeight: number; maxValue: number } + barShowLabels: boolean + } | undefined + if (hasBars && cfg.bars && cfg.barWidthSecs) { + const barMode = cfg.barMode ?? 'default' + const visibleBars: BarPoint[] = [] + for (const b of cfg.bars) { + if (b.time + cfg.barWidthSecs >= leftEdge && b.time <= rightEdge) { + visibleBars.push(b) + } + } + if (visibleBars.length > 0) { + let rawBarMax = 0 + for (const b of visibleBars) { if (b.value > rawBarMax) rawBarMax = b.value } + rawBarMax *= 1.05 + + // Smooth bar max — same lerp pattern as line chart Y range + targetBarMaxRef.current = rawBarMax + if (!barMaxInitedRef.current) { + displayBarMaxRef.current = rawBarMax + barMaxInitedRef.current = true + } else { + displayBarMaxRef.current = lerp(displayBarMaxRef.current, targetBarMaxRef.current, adaptiveSpeed, pausedDt) + const barStripPx = barMode === 'default' ? barStripH : chartH * BAR_OVERLAY_MAX_RATIO + const pxThreshold = barStripPx > 0 ? 0.5 * displayBarMaxRef.current / barStripPx : 0.001 + if (Math.abs(displayBarMaxRef.current - targetBarMaxRef.current) < pxThreshold) { + displayBarMaxRef.current = targetBarMaxRef.current + } + } + const barMax = displayBarMaxRef.current + + const barFillColor = cfg.barColor + ?? (barMode === 'overlay' ? cfg.palette.barFillOverlay : cfg.palette.barFill) + + const barLayout = barMode === 'default' + ? { bottom: h - pad.bottom, maxHeight: barStripH, maxValue: barMax } + : { bottom: pad.top + chartH, maxHeight: chartH * BAR_OVERLAY_MAX_RATIO, maxValue: barMax } + + candleBarDrawOpts = { + bars: visibleBars, + barWidthSecs: cfg.barWidthSecs, + barMode, + barFillColor, + barLayout, + barShowLabels: cfg.barLabels ?? false, + } + } + } + // --- Draw --- drawCandleFrame(ctx, layout, cfg.palette, { candles: drawCandles, @@ -1368,6 +1438,12 @@ export function useLivelineEngine( // loading→live (where loadingAlpha starts at ~1), while still // allowing smooth fade-out during empty→live (loadingAlpha is 0). showEmptyOverlay: !(cfg.loading ?? false) && loadingAlpha < 0.01, + bars: candleBarDrawOpts?.bars, + barWidthSecs: candleBarDrawOpts?.barWidthSecs, + barMode: candleBarDrawOpts?.barMode, + barFillColor: candleBarDrawOpts?.barFillColor, + barLayout: candleBarDrawOpts?.barLayout, + barShowLabels: candleBarDrawOpts?.barShowLabels, }) // Badge in candle mode — only when in line mode (lineModeProg > 0.5) @@ -1508,6 +1584,61 @@ export function useLivelineEngine( : 0 const swingMagnitude = valRange > 0 ? Math.min(recentDelta / valRange, 1) : 0 + // --- Bar chart computation --- + let barDrawOpts: { + bars: BarPoint[]; barWidthSecs: number; barMode: BarMode + barFillColor: string; barLayout: { bottom: number; maxHeight: number; maxValue: number } + barShowLabels: boolean + } | undefined + if (hasBars && cfg.bars && cfg.barWidthSecs) { + const barMode = cfg.barMode ?? 'default' + const visibleBars: BarPoint[] = [] + for (const b of cfg.bars) { + if (b.time + cfg.barWidthSecs >= leftEdge && b.time <= rightEdge) { + visibleBars.push(b) + } + } + if (visibleBars.length > 0) { + let rawBarMax = 0 + for (const b of visibleBars) { if (b.value > rawBarMax) rawBarMax = b.value } + rawBarMax *= 1.05 // 5% headroom + + // Smooth bar max — same lerp pattern as line chart Y range + targetBarMaxRef.current = rawBarMax + if (!barMaxInitedRef.current) { + displayBarMaxRef.current = rawBarMax + barMaxInitedRef.current = true + } else { + const gap = Math.abs(displayBarMaxRef.current - targetBarMaxRef.current) + const gapRatio = displayBarMaxRef.current > 0 ? Math.min(gap / displayBarMaxRef.current, 1) : 1 + const speed = RANGE_LERP_SPEED + (1 - gapRatio) * RANGE_ADAPTIVE_BOOST + displayBarMaxRef.current = lerp(displayBarMaxRef.current, targetBarMaxRef.current, speed, pausedDt) + const barStripPx = barMode === 'default' ? barStripH : chartH * BAR_OVERLAY_MAX_RATIO + const pxThreshold = barStripPx > 0 ? 0.5 * displayBarMaxRef.current / barStripPx : 0.001 + if (Math.abs(displayBarMaxRef.current - targetBarMaxRef.current) < pxThreshold) { + displayBarMaxRef.current = targetBarMaxRef.current + } + } + const barMax = displayBarMaxRef.current + + const barFillColor = cfg.barColor + ?? (barMode === 'overlay' ? cfg.palette.barFillOverlay : cfg.palette.barFill) + + const barLayout = barMode === 'default' + ? { bottom: h - pad.bottom, maxHeight: barStripH, maxValue: barMax } + : { bottom: pad.top + chartH, maxHeight: chartH * BAR_OVERLAY_MAX_RATIO, maxValue: barMax } + + barDrawOpts = { + bars: visibleBars, + barWidthSecs: cfg.barWidthSecs, + barMode, + barFillColor, + barLayout, + barShowLabels: cfg.barLabels ?? false, + } + } + } + // Draw canvas content (everything except badge) drawFrame(ctx, layout, cfg.palette, { visible, @@ -1542,6 +1673,12 @@ export function useLivelineEngine( chartReveal, pauseProgress, now_ms, + bars: barDrawOpts?.bars, + barWidthSecs: barDrawOpts?.barWidthSecs, + barMode: barDrawOpts?.barMode, + barFillColor: barDrawOpts?.barFillColor, + barLayout: barDrawOpts?.barLayout, + barShowLabels: barDrawOpts?.barShowLabels, }) // During morph (chart ↔ empty), overlay the gradient gap + text on From 78ce45c9090215c593078a2511feaed032e6a5d3 Mon Sep 17 00:00:00 2001 From: 0xChandi <183944703+0xChandi@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:02:45 -0500 Subject: [PATCH 2/4] Fix bar chart rendering in candle mode, consolidate demo pages - Fix out-of-scope adaptiveSpeed in candle pipeline causing bars to not render - Consolidate two bar chart sections into single chart with mode toggle - Add volume bars demo to index.html dev page Co-Authored-By: Claude Opus 4.6 --- dev/demo.tsx | 49 ++++++++------------ dev/main.tsx | 96 ++++++++++++++++++++++++++++++++++++++-- src/useLivelineEngine.ts | 5 ++- 3 files changed, 114 insertions(+), 36 deletions(-) diff --git a/dev/demo.tsx b/dev/demo.tsx index 1d006a5..96df007 100644 --- a/dev/demo.tsx +++ b/dev/demo.tsx @@ -129,6 +129,7 @@ function Demo() { const [volatility, setVolatility] = useState('normal') const [tickRate, setTickRate] = useState(300) const [bars, setBars] = useState([]) + const [barMode, setBarMode] = useState<'default' | 'overlay'>('default') const [barLabels, setBarLabels] = useState(false) const barBucketSecs = 2 @@ -392,7 +393,6 @@ function Demo() { Grid Scrub - Bar labels {/* Main chart */} @@ -473,36 +473,14 @@ function Demo() { ))}
- {/* Bar chart — default mode */} -

Volume bars (default)

-
- + {/* Volume bars */} +
+

Volume bars

+ setBarMode('default')}>Default + setBarMode('overlay')}>Overlay + + Labels
- - {/* Bar chart — overlay mode */} -

Volume bars (overlay)

setChartType(mode)} grid={grid} scrub={scrub} bars={bars} - barMode="overlay" + barMode={barMode} barWidth={barBucketSecs} barLabels={barLabels} /> diff --git a/dev/main.tsx b/dev/main.tsx index cefbe6f..3e4d911 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' import { createRoot } from 'react-dom/client' import { Liveline } from 'liveline' -import type { LivelinePoint } from 'liveline' +import type { LivelinePoint, BarPoint } from 'liveline' // --- Data generators --- @@ -19,6 +19,42 @@ function generatePoint(prev: number, time: number, volatility: Volatility): Live return { time, value: prev + delta } } +/** Generate initial volume bars from seed data */ +function generateBars(data: LivelinePoint[], bucketSecs: number): BarPoint[] { + if (data.length === 0) return [] + const bars: BarPoint[] = [] + const startTime = Math.floor(data[0].time / bucketSecs) * bucketSecs + let bucketStart = startTime + let bucketVolume = 0 + let prevValue = data[0].value + for (const pt of data) { + while (pt.time >= bucketStart + bucketSecs) { + if (bucketVolume > 0) bars.push({ time: bucketStart, value: bucketVolume }) + bucketStart += bucketSecs + bucketVolume = 0 + } + const change = Math.abs(pt.value - prevValue) + bucketVolume += 10 + change * 20 + Math.random() * 5 + prevValue = pt.value + } + if (bucketVolume > 0) bars.push({ time: bucketStart, value: bucketVolume }) + return bars +} + +/** Incrementally update bars with a new data point — only touches the last bucket */ +function addTickToBars(bars: BarPoint[], pt: LivelinePoint, prevValue: number, bucketSecs: number): BarPoint[] { + const bucketStart = Math.floor(pt.time / bucketSecs) * bucketSecs + const change = Math.abs(pt.value - prevValue) + const volume = 10 + change * 20 + Math.random() * 5 + if (bars.length === 0 || bars[bars.length - 1].time < bucketStart) { + return [...bars, { time: bucketStart, value: volume }] + } + const updated = bars.slice() + const last = updated[updated.length - 1] + updated[updated.length - 1] = { time: last.time, value: last.value + volume } + return updated +} + // --- Constants --- const TIME_WINDOWS = [ @@ -60,6 +96,10 @@ function Demo() { const [scrub, setScrub] = useState(true) const [exaggerate, setExaggerate] = useState(false) const [theme, setTheme] = useState<'dark' | 'light'>('dark') + const [bars, setBars] = useState([]) + const [barMode, setBarMode] = useState<'default' | 'overlay'>('default') + const [barLabels, setBarLabels] = useState(false) + const barBucketSecs = 2 // Data controls const [volatility, setVolatility] = useState('normal') @@ -68,6 +108,7 @@ function Demo() { const intervalRef = useRef(0) const volatilityRef = useRef(volatility) volatilityRef.current = volatility + const lastValueRef = useRef(100) const startLive = useCallback(() => { clearInterval(intervalRef.current) @@ -83,13 +124,18 @@ function Demo() { } setData(seed) setValue(v) + lastValueRef.current = v + setBars(generateBars(seed, barBucketSecs)) intervalRef.current = window.setInterval(() => { setData(prev => { const now = Date.now() / 1000 const lastVal = prev.length > 0 ? prev[prev.length - 1].value : 100 + const prevVal = lastValueRef.current const pt = generatePoint(lastVal, now, volatilityRef.current) + lastValueRef.current = pt.value setValue(pt.value) + setBars(b => addTickToBars(b, pt, prevVal, barBucketSecs)) const next = [...prev, pt] return next.length > 500 ? next.slice(-500) : next }) @@ -99,7 +145,7 @@ function Demo() { useEffect(() => { if (scenario === 'loading') { setLoading(true) - setData([]) + setData([]); setBars([]) clearInterval(intervalRef.current) const timer = setTimeout(() => setScenario('live'), 3000) return () => clearTimeout(timer) @@ -107,14 +153,14 @@ function Demo() { if (scenario === 'loading-hold') { setLoading(true) - setData([]) + setData([]); setBars([]) clearInterval(intervalRef.current) return } if (scenario === 'empty') { setLoading(false) - setData([]) + setData([]); setBars([]) clearInterval(intervalRef.current) return } @@ -132,8 +178,11 @@ function Demo() { setData(prev => { const now = Date.now() / 1000 const lastVal = prev.length > 0 ? prev[prev.length - 1].value : 100 + const prevVal = lastValueRef.current const pt = generatePoint(lastVal, now, volatilityRef.current) + lastValueRef.current = pt.value setValue(pt.value) + setBars(b => addTickToBars(b, pt, prevVal, barBucketSecs)) const next = [...prev, pt] return next.length > 500 ? next.slice(-500) : next }) @@ -314,6 +363,45 @@ function Demo() { ))}
+ {/* Volume bars */} +
+

Volume bars

+ setBarMode('default')}>Default + setBarMode('overlay')}>Overlay + + Labels +
+
+ +
+ {/* Status bar */}
0 ? Math.min(gap / displayBarMaxRef.current, 1) : 1 + const barSpeed = RANGE_LERP_SPEED + (1 - gapRatio) * RANGE_ADAPTIVE_BOOST + displayBarMaxRef.current = lerp(displayBarMaxRef.current, targetBarMaxRef.current, barSpeed, pausedDt) const barStripPx = barMode === 'default' ? barStripH : chartH * BAR_OVERLAY_MAX_RATIO const pxThreshold = barStripPx > 0 ? 0.5 * displayBarMaxRef.current / barStripPx : 0.001 if (Math.abs(displayBarMaxRef.current - targetBarMaxRef.current) < pxThreshold) { From 2aa3f21f7ede0877d3d387cc2a9df7e8c9a5cd4a Mon Sep 17 00:00:00 2001 From: 0xChandi <183944703+0xChandi@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:05:45 -0500 Subject: [PATCH 3/4] Reduce right padding when badge/grid are off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right padding was hardcoded to 80px (space for the badge). Now it adapts: 80px with badge, 54px with grid labels only, 12px with both off — so the chart fills the available width. Co-Authored-By: Claude Opus 4.6 --- src/Liveline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Liveline.tsx b/src/Liveline.tsx index 9a5be23..05d2b44 100644 --- a/src/Liveline.tsx +++ b/src/Liveline.tsx @@ -81,9 +81,10 @@ export function Liveline({ const momentumOverride: Momentum | undefined = typeof momentum === 'string' ? momentum : undefined + const defaultRight = badge ? 80 : grid ? 54 : 12 const pad = { top: paddingOverride?.top ?? 12, - right: paddingOverride?.right ?? 80, + right: paddingOverride?.right ?? defaultRight, bottom: paddingOverride?.bottom ?? 28, left: paddingOverride?.left ?? 12, } From fc8e8b41286fb9768ec501b17c6340b6aa983aa0 Mon Sep 17 00:00:00 2001 From: 0xChandi <183944703+0xChandi@users.noreply.github.com> Date: Wed, 25 Feb 2026 02:14:50 -0500 Subject: [PATCH 4/4] Dynamic right-side buffer based on badge visibility Reduce the time-axis buffer from 5% to 1.5% when the badge is hidden, so the live dot and fill extend closer to the chart edge. Candle mode always uses the smaller buffer since it never renders a badge. Add badge toggle to the demo page. Co-Authored-By: Claude Opus 4.6 --- dev/demo.tsx | 5 +++++ src/useLivelineEngine.ts | 25 ++++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/dev/demo.tsx b/dev/demo.tsx index 96df007..462a239 100644 --- a/dev/demo.tsx +++ b/dev/demo.tsx @@ -124,6 +124,7 @@ function Demo() { const [windowSecs, setWindowSecs] = useState(30) const [theme, setTheme] = useState<'dark' | 'light'>('dark') const [grid, setGrid] = useState(true) + const [badge, setBadge] = useState(true) const [scrub, setScrub] = useState(true) const [volatility, setVolatility] = useState('normal') @@ -392,6 +393,7 @@ function Demo() { setTheme('light')}>Light Grid + Badge Scrub @@ -424,6 +426,7 @@ function Demo() { formatValue={preset === 'crypto' ? formatCrypto : undefined} onModeChange={(mode) => setChartType(mode)} grid={grid} + badge={badge} scrub={scrub} />
@@ -466,6 +469,7 @@ function Demo() { window={windowSecs} formatValue={preset === 'crypto' ? formatCrypto : undefined} grid={grid && size.w >= 200} + badge={badge && size.w >= 200} scrub={scrub} />
@@ -508,6 +512,7 @@ function Demo() { formatValue={preset === 'crypto' ? formatCrypto : undefined} onModeChange={(mode) => setChartType(mode)} grid={grid} + badge={badge} scrub={scrub} bars={bars} barMode={barMode} diff --git a/src/useLivelineEngine.ts b/src/useLivelineEngine.ts index 998de2f..e3848fa 100644 --- a/src/useLivelineEngine.ts +++ b/src/useLivelineEngine.ts @@ -81,6 +81,7 @@ const BADGE_Y_LERP_TRANSITIONING = 0.5 const MOMENTUM_COLOR_LERP = 0.12 const WINDOW_TRANSITION_MS = 750 const WINDOW_BUFFER = 0.05 +const WINDOW_BUFFER_NO_BADGE = 0.015 const VALUE_SNAP_THRESHOLD = 0.001 const ADAPTIVE_SPEED_BOOST = 0.2 const MOMENTUM_GREEN: [number, number, number] = [34, 197, 94] @@ -108,6 +109,7 @@ const LINE_SNAP_THRESHOLD = 0.001 const RANGE_LERP_SPEED = 0.15 const RANGE_ADAPTIVE_BOOST = 0.2 const CANDLE_BUFFER = 0.05 +const CANDLE_BUFFER_NO_BADGE = 0.015 // --- Extracted helper functions (pure computation, called inside draw loop) --- @@ -964,6 +966,10 @@ export function useLivelineEngine( // CANDLE MODE PIPELINE // ═══════════════════════════════════════════════════════ + // Badge is never visible in pure candle mode (only during line morph), + // so always use the smaller buffer to avoid dead space on the right. + const candleBuffer = CANDLE_BUFFER_NO_BADGE + // Frozen now — prevent candles from scrolling during reverse morph if (hasData) frozenNowRef.current = Date.now() / 1000 - timeDebtRef.current const now = (hasData || chartReveal < 0.005) @@ -1008,7 +1014,7 @@ export function useLivelineEngine( cwt.rangeFromMin = displayMinRef.current cwt.rangeFromMax = displayMaxRef.current const curWindow = displayWindowRef.current - const re = now + curWindow * CANDLE_BUFFER + const re = now + curWindow * candleBuffer const le = re - curWindow const targetVis: CandlePoint[] = [] for (const c of effectiveCandles) { @@ -1054,14 +1060,14 @@ export function useLivelineEngine( const windowResult = updateCandleWindowTransition( cfg.windowSecs, transition, displayWindowRef.current, displayMinRef.current, displayMaxRef.current, - now_ms, now, effectiveCandles, rawLive, candleWidthSecs, CANDLE_BUFFER, + now_ms, now, effectiveCandles, rawLive, candleWidthSecs, candleBuffer, ) displayWindowRef.current = windowResult.windowSecs const windowSecs = windowResult.windowSecs const windowTransProgress = windowResult.windowTransProgress const isWindowTransitioning = transition.startMs > 0 - const rightEdge = now + windowSecs * CANDLE_BUFFER + const rightEdge = now + windowSecs * candleBuffer const leftEdge = rightEdge - windowSecs // --- Live candle OHLC lerp --- @@ -1501,13 +1507,14 @@ export function useLivelineEngine( const chartW = w - pad.left - pad.right - // Dynamic buffer: when momentum arrows + badge are both on, ensure enough - // gap between the live dot and badge for the arrows to fit. - // Gap formula: buffer * chartW - 7. Need ~30px for arrows. - const needsArrowRoom = cfg.showMomentum + // Dynamic buffer: when badge is off, use a smaller buffer so the dot + // sits closer to the right edge. When momentum arrows + badge are both + // on, ensure enough gap for the arrows to fit. + const baseBuffer = cfg.showBadge ? WINDOW_BUFFER : WINDOW_BUFFER_NO_BADGE + const needsArrowRoom = cfg.showMomentum && cfg.showBadge const buffer = needsArrowRoom - ? Math.max(WINDOW_BUFFER, 37 / Math.max(chartW, 1)) - : WINDOW_BUFFER + ? Math.max(baseBuffer, 37 / Math.max(chartW, 1)) + : baseBuffer // Window transition const transition = windowTransitionRef.current