diff --git a/dev/demo.tsx b/dev/demo.tsx index b9a51e2..462a239 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 = [ @@ -88,10 +124,15 @@ 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') const [tickRate, setTickRate] = useState(300) + const [bars, setBars] = useState([]) + const [barMode, setBarMode] = useState<'default' | 'overlay'>('default') + const [barLabels, setBarLabels] = useState(false) + const barBucketSecs = 2 const [chartType, setChartType] = useState<'line' | 'candle'>('candle') const [candleSecs, setCandleSecs] = useState(2) @@ -162,10 +203,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 +218,7 @@ function Demo() { return trimmed }) tickAndAggregate(pt) + setBars(prev => addTickToBars(prev, pt, prevVal, barBucketSecs)) }, tickRate) }, [tickRate]) @@ -182,7 +226,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 +235,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 +243,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 +257,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 +268,7 @@ function Demo() { return trimmed }) tickAndAggregate(pt) + setBars(prev => addTickToBars(prev, pt, prevVal, barBucketSecs)) }, tickRate) return () => clearInterval(intervalRef.current) }, [tickRate, scenario]) @@ -260,7 +306,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) @@ -347,6 +393,7 @@ function Demo() { setTheme('light')}>Light Grid + Badge Scrub @@ -379,6 +426,7 @@ function Demo() { formatValue={preset === 'crypto' ? formatCrypto : undefined} onModeChange={(mode) => setChartType(mode)} grid={grid} + badge={badge} scrub={scrub} /> @@ -421,6 +469,7 @@ function Demo() { window={windowSecs} formatValue={preset === 'crypto' ? formatCrypto : undefined} grid={grid && size.w >= 200} + badge={badge && size.w >= 200} scrub={scrub} /> @@ -428,6 +477,50 @@ function Demo() { ))} + {/* Volume bars */} +
+

Volume bars

+ setBarMode('default')}>Default + setBarMode('overlay')}>Overlay + + Labels +
+
+ setChartType(mode)} + grid={grid} + badge={badge} + scrub={scrub} + bars={bars} + barMode={barMode} + barWidth={barBucketSecs} + barLabels={barLabels} + /> +
+ {/* Status bar */}
= 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 const [windowStyle, setWindowStyle] = useState<'default' | 'rounded' | 'text'>('default') const [lineMode, setLineMode] = useState(true) @@ -70,6 +110,7 @@ function Demo() { const intervalRef = useRef(0) const volatilityRef = useRef(volatility) volatilityRef.current = volatility + const lastValueRef = useRef(100) const startLive = useCallback(() => { clearInterval(intervalRef.current) @@ -85,13 +126,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 }) @@ -101,7 +147,7 @@ function Demo() { useEffect(() => { if (scenario === 'loading') { setLoading(true) - setData([]) + setData([]); setBars([]) clearInterval(intervalRef.current) const timer = setTimeout(() => setScenario('live'), 3000) return () => clearTimeout(timer) @@ -109,14 +155,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 } @@ -134,8 +180,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 }) @@ -325,6 +374,45 @@ function Demo() { ))}
+ {/* Volume bars */} +
+

Volume bars

+ setBarMode('default')}>Default + setBarMode('overlay')}>Overlay + + Labels +
+
+ +
+ {/* 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 dd4aefd..42934e3 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, drawSimpleDot, drawMultiDot } from './dot' @@ -10,6 +10,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 @@ -60,6 +61,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 } /** @@ -125,6 +133,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) @@ -191,6 +219,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() @@ -451,6 +502,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 } /** @@ -498,6 +556,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 @@ -601,6 +679,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 4c619ce..e93880e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,4 +16,6 @@ export type { DegenOptions, WindowStyle, BadgeVariant, + BarPoint, + BarMode, } from './types' diff --git a/src/theme.ts b/src/theme.ts index b1c716b..545bad4 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 2f2f6fd..4a4d63e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -120,6 +120,13 @@ export interface LivelineProps { onSeriesToggle?: (id: string, visible: boolean) => void // Multi-series toggle callback seriesToggleCompact?: boolean // Show only colored dots (no labels) in series toggle (default: false) + // 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 } @@ -132,6 +139,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 @@ -178,6 +192,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 90d80e9..39df7b0 100644 --- a/src/useLivelineEngine.ts +++ b/src/useLivelineEngine.ts @@ -1,5 +1,5 @@ import { useRef, useEffect, useCallback } from 'react' -import type { LivelinePoint, LivelinePalette, LivelineSeries, Momentum, ReferenceLine, HoverPoint, Padding, ChartLayout, OrderbookData, DegenOptions, BadgeVariant, CandlePoint } from './types' +import type { LivelinePoint, LivelinePalette, LivelineSeries, 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' @@ -54,6 +54,12 @@ interface EngineConfig { lineData?: LivelinePoint[] lineValue?: number + // Bar chart + bars?: BarPoint[] + barMode?: BarMode + barColor?: string + barWidthSecs?: number + barLabels?: boolean // Multi-series mode multiSeries?: Array<{ id: string @@ -86,6 +92,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] @@ -98,6 +105,10 @@ const PAUSE_CATCHUP_SPEED_FAST = 0.22 const LOADING_ALPHA_SPEED = 0.14 const SERIES_TOGGLE_SPEED = 0.10 +// --- 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 @@ -110,6 +121,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) --- @@ -584,6 +596,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) @@ -861,7 +876,11 @@ export function useLivelineEngine( const hasMultiData = cfg.isMultiSeries && cfg.multiSeries ? cfg.multiSeries.some(s => s.data.length >= 2) : false const hasData = isCandle ? effectiveCandles.length >= 2 : (hasMultiData || 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 @@ -988,6 +1007,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) @@ -1032,7 +1055,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) { @@ -1078,14 +1101,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 --- @@ -1368,6 +1391,61 @@ 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 { + const gap = Math.abs(displayBarMaxRef.current - targetBarMaxRef.current) + const gapRatio = displayBarMaxRef.current > 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) { + 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, @@ -1410,6 +1488,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) @@ -1735,13 +1819,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 @@ -1821,6 +1906,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, @@ -1855,6 +1995,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