Awesome Cursor Rules
libliveline-coresource: ok · refreshed
Liveline Core
Framework-independent canvas engine, drawing, and math modules for Liveline.
npx shadcn add "https://react.tileui.zmorg.cn/r/liveline-core.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files25 · source cached 10/2/2026, 12:45:28 AM
packages/core/src/liveline/index.ts
export * from './types';
export * from './theme';
export * from './engine';
export * from './math/lerp';
export * from './math/range';
export * from './math/momentum';
export * from './math/interpolate';
export * from './math/intervals';
export * from './math/spline';
packages/core/src/liveline/types.ts
export interface LivelinePoint {
time: number; // unix seconds
value: number;
}
export type Momentum = 'up' | 'down' | 'flat';
export type ThemeMode = 'light' | 'dark';
export type WindowStyle = 'default' | 'rounded' | 'text';
export type BadgeVariant = 'default' | 'minimal';
export interface ReferenceLine {
value: number;
label?: string;
}
export interface HoverPoint {
time: number;
value: number;
x: number;
y: number;
}
export interface Padding {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface WindowOption {
label: string;
secs: number;
}
export interface OrderbookData {
bids: [number, number][]; // [price, size][]
asks: [number, number][]; // [price, size][]
}
export interface DegenOptions {
/** Multiplier for particle count and size (default 1) */
scale?: number;
/** Show particles on down-momentum swings (default false) */
downMomentum?: boolean;
}
export interface LivelineSeries {
id: string;
data: LivelinePoint[];
value: number;
color: string;
label?: string;
}
export interface LivelineOptions {
data: LivelinePoint[];
value: number;
// Multi-series mode — when provided, overrides data/value/color
series?: LivelineSeries[];
// Appearance
theme?: ThemeMode;
color?: string;
// Time
window?: number;
// Feature flags
grid?: boolean;
badge?: boolean;
momentum?: boolean | Momentum;
fill?: boolean;
loading?: boolean; // Show loading animation — breathing line (default: false)
paused?: boolean; // Pause chart scrolling (default: false)
emptyText?: string; // Text shown in the empty state (default: 'No data to display')
scrub?: boolean; // Enable crosshair scrubbing on hover (default: true)
exaggerate?: boolean; // Tight Y-axis range — small moves fill chart height (default: false)
showValue?: boolean; // Show live value as DOM text overlay (default: false)
valueMomentumColor?: boolean; // Color the value text by momentum — green/red (default: false)
degen?: boolean | DegenOptions; // Degen mode — burst particles + chart shake on momentum swings (default: false)
badgeTail?: boolean; // Show pointed tail on badge pill (default: true)
// Time window buttons
windows?: WindowOption[];
onWindowChange?: (secs: number) => void;
windowStyle?: WindowStyle;
// Badge
badgeVariant?: BadgeVariant; // Badge visual style: 'default' (accent) or 'minimal' (white + grey text)
// Crosshair
tooltipY?: number; // Vertical offset for crosshair tooltip text (default: 14)
tooltipOutline?: boolean; // Stroke outline around crosshair tooltip text for readability (default: true)
// Orderbook
orderbook?: OrderbookData;
// Optional
referenceLine?: ReferenceLine;
formatValue?: (v: number) => string;
formatTime?: (t: number) => string;
lerpSpeed?: number;
padding?: Padding;
onHover?: (point: HoverPoint | null) => void;
cursor?: string; // CSS cursor on hover (default: 'crosshair')
pulse?: boolean; // Pulsing ring on live dot (default: true)
lineWidth?: number; // Stroke width of the main line in px (default: 2)
// Candlestick mode
mode?: 'line' | 'candle'; // Chart type (default: 'line')
candles?: CandlePoint[]; // OHLC candle data (required when mode='candle')
candleWidth?: number; // Seconds per candle (required when mode='candle')
liveCandle?: CandlePoint; // Current live candle with real-time OHLC
lineMode?: boolean; // Morph candles into line display
lineData?: LivelinePoint[]; // Tick-level data for density transition
lineValue?: number; // Current tick value for density transition
onModeChange?: (mode: 'line' | 'candle') => void; // Built-in toggle callback
onSeriesToggle?: (id: string, visible: boolean) => void; // Multi-series toggle callback
seriesToggleCompact?: boolean; // Show only colored dots (no labels) in series toggle (default: false)
}
/** Framework adapters may extend this with their own host styling props. */
export type LivelineProps = LivelineOptions;
export interface CandlePoint {
time: number; // unix seconds — candle open time
open: number;
high: number;
low: number;
close: number;
}
export interface LivelinePalette {
// Line
line: string;
lineWidth: number;
// Fill gradient
fillTop: string;
fillBottom: string;
// Grid
gridLine: string;
gridLabel: string;
// Dot
dotUp: string;
dotDown: string;
dotFlat: string;
glowUp: string;
glowDown: string;
glowFlat: string;
// Badge
badgeOuterBg: string;
badgeOuterShadow: string;
badgeBg: string;
badgeText: string;
// Dash line
dashLine: string;
// Reference line
refLine: string;
refLabel: string;
// Time axis
timeLabel: string;
// Crosshair
crosshairLine: string;
tooltipBg: string;
tooltipText: string;
tooltipBorder: string;
// Background (for color fading — labels fade toward bg instead of alpha)
bgRgb: [number, number, number];
// Fonts
labelFont: string;
valueFont: string;
badgeFont: string;
}
export interface ChartLayout {
w: number;
h: number;
pad: Required<Padding>;
chartW: number;
chartH: number;
leftEdge: number;
rightEdge: number;
minVal: number;
maxVal: number;
valRange: number;
toX: (t: number) => number;
toY: (v: number) => number;
}
packages/core/src/liveline/theme.ts
import type { ThemeMode, LivelinePalette, LivelineSeries } from './types';
/** Parse any CSS color string to [r, g, b]. Handles hex (#rgb, #rrggbb), rgb(), rgba(). */
export function parseColorRgb(color: string): [number, number, number] {
const hex = color.match(/^#([0-9a-f]{3,8})$/i);
if (hex) {
let h = hex[1];
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
const rgb = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) return [+rgb[1], +rgb[2], +rgb[3]];
return [128, 128, 128];
}
function rgba(r: number, g: number, b: number, a: number): string {
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Derive a full palette from a single accent color + theme mode.
* Momentum colors are always semantic green/red regardless of accent.
*/
export function resolveTheme(color: string, mode: ThemeMode): LivelinePalette {
const [r, g, b] = parseColorRgb(color);
const isDark = mode === 'dark';
return {
// Line
line: color,
lineWidth: 2,
// Fill gradient
fillTop: rgba(r, g, b, isDark ? 0.12 : 0.08),
fillBottom: rgba(r, g, b, 0),
// Grid
gridLine: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
gridLabel: isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.35)',
// Dot — always semantic
dotUp: '#22c55e',
dotDown: '#ef4444',
dotFlat: color,
glowUp: 'rgba(34, 197, 94, 0.18)',
glowDown: 'rgba(239, 68, 68, 0.18)',
glowFlat: rgba(r, g, b, 0.12),
// Badge
badgeOuterBg: isDark ? 'rgba(40, 40, 40, 0.95)' : 'rgba(255, 255, 255, 0.95)',
badgeOuterShadow: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.15)',
badgeBg: color,
badgeText: '#ffffff',
// Dash line
dashLine: rgba(r, g, b, 0.4),
// Reference line
refLine: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.12)',
refLabel: isDark ? 'rgba(255, 255, 255, 0.45)' : 'rgba(0, 0, 0, 0.4)',
// Time axis
timeLabel: isDark ? 'rgba(255, 255, 255, 0.35)' : 'rgba(0, 0, 0, 0.3)',
// Crosshair
crosshairLine: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.12)',
tooltipBg: isDark ? 'rgba(30, 30, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
tooltipText: isDark ? '#e5e5e5' : '#1a1a1a',
tooltipBorder: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)',
// Background
bgRgb: isDark ? ([10, 10, 10] as [number, number, number]) : ([255, 255, 255] as [number, number, number]),
// Fonts
labelFont: '11px "SF Mono", Menlo, Monaco, "Cascadia Code", monospace',
valueFont: '600 11px "SF Mono", Menlo, monospace',
badgeFont: '500 11px "SF Mono", Menlo, monospace',
};
}
/** Default color palette for multi-series when no colors specified. */
export const SERIES_COLORS = [
'#3b82f6', // blue
'#ef4444', // red
'#22c55e', // green
'#f59e0b', // amber
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
];
/** Derive per-series palettes from series definitions. */
export function resolveSeriesPalettes(series: LivelineSeries[], mode: ThemeMode): Map<string, LivelinePalette> {
const map = new Map<string, LivelinePalette>();
for (let i = 0; i < series.length; i++) {
const s = series[i];
const color = s.color || SERIES_COLORS[i % SERIES_COLORS.length];
map.set(s.id, resolveTheme(color, mode));
}
return map;
}
packages/core/src/liveline/engine.ts
import type { LivelinePoint, LivelinePalette, Momentum, ReferenceLine, HoverPoint, Padding, ChartLayout, OrderbookData, DegenOptions, BadgeVariant, CandlePoint } from './types';
import { lerp } from './math/lerp';
import { computeRange } from './math/range';
import { detectMomentum } from './math/momentum';
import { interpolateAtTime } from './math/interpolate';
import { getDpr, applyDpr } from './canvas/dpr';
import { drawFrame, drawCandleFrame, drawMultiFrame, FADE_EDGE_WIDTH } from './draw';
import type { MultiSeriesEntry } from './draw';
import { drawLoading } from './draw/loading';
import { drawEmpty } from './draw/empty';
import { createOrderbookState } from './draw/orderbook';
import { createParticleState } from './draw/particles';
import { createShakeState } from './draw';
import { badgeSvgPath, badgePillOnly, BADGE_PAD_X, BADGE_PAD_Y, BADGE_TAIL_LEN, BADGE_TAIL_SPREAD, BADGE_LINE_H } from './draw/badge';
export interface LivelineEngineConfig {
data: LivelinePoint[];
value: number;
palette: LivelinePalette;
windowSecs: number;
lerpSpeed: number;
showGrid: boolean;
showBadge: boolean;
showMomentum: boolean;
momentumOverride?: Momentum;
showFill: boolean;
referenceLine?: ReferenceLine;
formatValue: (v: number) => string;
formatTime: (t: number) => string;
padding: Required<Padding>;
onHover?: (point: HoverPoint | null) => void;
showPulse: boolean;
scrub: boolean;
exaggerate: boolean;
degenOptions?: DegenOptions;
badgeTail: boolean;
badgeVariant: BadgeVariant;
tooltipY: number;
tooltipOutline: boolean;
valueMomentumColor: boolean;
valueElement?: HTMLElement | null;
orderbookData?: OrderbookData;
loading?: boolean;
paused?: boolean;
emptyText?: string;
// Candlestick mode
mode: 'line' | 'candle';
candles?: CandlePoint[];
candleWidth?: number;
liveCandle?: CandlePoint;
lineMode?: boolean;
lineData?: LivelinePoint[];
lineValue?: number;
// Multi-series mode
multiSeries?: Array<{
id: string;
data: LivelinePoint[];
value: number;
palette: LivelinePalette;
label?: string;
}>;
isMultiSeries?: boolean;
hiddenSeriesIds?: Set<string>;
}
export interface LivelineEngineElements {
container: HTMLElement;
canvas: HTMLCanvasElement;
value?: HTMLElement | null;
}
export interface LivelineEngineEnvironment {
document?: Document;
now?: () => number;
performanceNow?: () => number;
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
cancelAnimationFrame?: (handle: number) => void;
ResizeObserver?: typeof ResizeObserver;
matchMedia?: (query: string) => MediaQueryList;
devicePixelRatio?: number;
}
export interface LivelineEngine {
update(config: LivelineEngineConfig): void;
destroy(): void;
}
interface MutableRef<T> {
current: T;
}
const useRef = <T>(value: T): MutableRef<T> => ({ current: value });
type EngineConfig = LivelineEngineConfig;
interface BadgeEls {
container: HTMLDivElement;
svg: SVGSVGElement;
path: SVGPathElement;
text: HTMLSpanElement;
displayW: number; // current lerped text width
targetW: number; // target text width
}
const SVG_NS = 'http://www.w3.org/2000/svg';
// --- Constants ---
const MAX_DELTA_MS = 50;
const SCRUB_LERP_SPEED = 0.12;
const BADGE_WIDTH_LERP = 0.15;
const BADGE_Y_LERP = 0.35;
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];
const MOMENTUM_RED: [number, number, number] = [239, 68, 68];
const CHART_REVEAL_SPEED = 0.14; // data → loading/empty (reverse)
const CHART_REVEAL_SPEED_FWD = 0.09; // loading/empty → data (forward, slower for choreography)
const PAUSE_PROGRESS_SPEED = 0.12;
const PAUSE_CATCHUP_SPEED = 0.08;
const PAUSE_CATCHUP_SPEED_FAST = 0.22;
const LOADING_ALPHA_SPEED = 0.14;
const SERIES_TOGGLE_SPEED = 0.1;
const TOUCH_AXIS_THRESHOLD = 6;
// --- Candle-specific constants ---
const CANDLE_LERP_SPEED = 0.25;
const CANDLE_WIDTH_TRANS_MS = 300;
const LINE_MORPH_MS = 500;
const CLOSE_LINE_LERP_SPEED = 0.25; // matches candle body speed
const LINE_DENSITY_MS = 350;
const LINE_LERP_BASE = 0.08;
const LINE_ADAPTIVE_BOOST = 0.2;
const LINE_SNAP_THRESHOLD = 0.001;
const RANGE_LERP_SPEED = 0.15;
const RANGE_ADAPTIVE_BOOST = 0.2;
const CANDLE_BUFFER_NO_BADGE = 0.015;
// --- Extracted helper functions (pure computation, called inside draw loop) ---
interface WindowTransState {
from: number;
to: number;
startMs: number;
rangeFromMin: number;
rangeFromMax: number;
rangeToMin: number;
rangeToMax: number;
}
/** Lerp display value with adaptive speed — slow for big jumps, fast for small ticks. */
function computeAdaptiveSpeed(value: number, displayValue: number, displayMin: number, displayMax: number, lerpSpeed: number, noMotion: boolean): number {
const valGap = Math.abs(value - displayValue);
const prevRange = displayMax - displayMin || 1;
const gapRatio = Math.min(valGap / prevRange, 1);
return noMotion ? 1 : lerpSpeed + (1 - gapRatio) * ADAPTIVE_SPEED_BOOST;
}
/** Update window transition state, returning current display window and transition progress. */
function updateWindowTransition(
cfg: EngineConfig,
wt: WindowTransState,
displayWindow: number,
displayMin: number,
displayMax: number,
noMotion: boolean,
now_ms: number,
now: number,
points: LivelinePoint[],
smoothValue: number,
buffer: number,
): { windowSecs: number; windowTransProgress: number } {
if (wt.to !== cfg.windowSecs) {
wt.from = displayWindow;
wt.to = cfg.windowSecs;
wt.startMs = now_ms;
wt.rangeFromMin = displayMin;
wt.rangeFromMax = displayMax;
const targetRightEdge = now + cfg.windowSecs * buffer;
const targetLeftEdge = targetRightEdge - cfg.windowSecs;
const targetVisible: LivelinePoint[] = [];
for (const p of points) {
if (p.time >= targetLeftEdge - 2 && p.time <= targetRightEdge) {
targetVisible.push(p);
}
}
if (targetVisible.length > 0) {
const targetRange = computeRange(targetVisible, smoothValue, cfg.referenceLine?.value, cfg.exaggerate);
wt.rangeToMin = targetRange.min;
wt.rangeToMax = targetRange.max;
}
}
let windowTransProgress = 0;
let resultWindow: number;
if (noMotion || wt.startMs === 0) {
resultWindow = cfg.windowSecs;
wt.startMs = 0;
} else {
const elapsed = now_ms - wt.startMs;
const duration = WINDOW_TRANSITION_MS;
const t = Math.min(elapsed / duration, 1);
const eased = (1 - Math.cos(t * Math.PI)) / 2;
windowTransProgress = eased;
const logFrom = Math.log(wt.from);
const logTo = Math.log(wt.to);
resultWindow = Math.exp(logFrom + (logTo - logFrom) * eased);
if (t >= 1) {
resultWindow = cfg.windowSecs;
wt.startMs = 0;
windowTransProgress = 0;
}
}
return { windowSecs: resultWindow, windowTransProgress };
}
/** Smooth Y range with lerp. During window transitions, interpolates between pre-computed ranges. */
function updateRange(
computedRange: { min: number; max: number },
rangeInited: boolean,
targetMin: number,
targetMax: number,
displayMin: number,
displayMax: number,
isTransitioning: boolean,
windowTransProgress: number,
wt: WindowTransState,
adaptiveSpeed: number,
chartH: number,
dt: number,
): { minVal: number; maxVal: number; valRange: number; targetMin: number; targetMax: number; displayMin: number; displayMax: number; rangeInited: boolean } {
if (!rangeInited) {
return {
minVal: computedRange.min,
maxVal: computedRange.max,
valRange: computedRange.max - computedRange.min || 0.001,
targetMin: computedRange.min,
targetMax: computedRange.max,
displayMin: computedRange.min,
displayMax: computedRange.max,
rangeInited: true,
};
}
if (isTransitioning) {
displayMin = wt.rangeFromMin + (wt.rangeToMin - wt.rangeFromMin) * windowTransProgress;
displayMax = wt.rangeFromMax + (wt.rangeToMax - wt.rangeFromMax) * windowTransProgress;
targetMin = computedRange.min;
targetMax = computedRange.max;
} else {
const curRange = displayMax - displayMin;
targetMin = computedRange.min;
targetMax = computedRange.max;
displayMin = lerp(displayMin, targetMin, adaptiveSpeed, dt);
displayMax = lerp(displayMax, targetMax, adaptiveSpeed, dt);
const pxThreshold = (0.5 * curRange) / chartH || 0.001;
if (Math.abs(displayMin - targetMin) < pxThreshold) displayMin = targetMin;
if (Math.abs(displayMax - targetMax) < pxThreshold) displayMax = targetMax;
}
return {
minVal: displayMin,
maxVal: displayMax,
valRange: displayMax - displayMin || 0.001,
targetMin,
targetMax,
displayMin,
displayMax,
rangeInited: true,
};
}
/** Compute hover position, interpolated value, and scrub amount. */
function updateHoverState(
hoverPixelX: number | null,
pad: Required<Padding>,
w: number,
layout: ChartLayout,
now: number,
visible: LivelinePoint[],
scrubAmount: number,
lastHover: { x: number; value: number; time: number } | null,
cfg: EngineConfig,
noMotion: boolean,
leftEdge: number,
rightEdge: number,
chartW: number,
): {
hoverX: number | null;
hoverValue: number | null;
hoverTime: number | null;
scrubAmount: number;
isActiveHover: boolean;
lastHover: { x: number; value: number; time: number } | null;
} {
let hoverValue: number | null = null;
let hoverTime: number | null = null;
let hoverChartX: number | null = null;
let isActiveHover = false;
if (hoverPixelX !== null && hoverPixelX >= pad.left && hoverPixelX <= w - pad.right) {
const maxHoverX = layout.toX(now);
const clampedX = Math.min(hoverPixelX, maxHoverX);
const t = leftEdge + ((clampedX - pad.left) / chartW) * (rightEdge - leftEdge);
const v = visible.length > 0 && t >= visible[0].time ? interpolateAtTime(visible, t) : null;
if (v !== null) {
hoverValue = v;
hoverTime = t;
hoverChartX = clampedX;
isActiveHover = true;
lastHover = { x: clampedX, value: v, time: t };
cfg.onHover?.({ time: t, value: v, x: clampedX, y: layout.toY(v) });
}
}
// Lerp scrub amount
if (!isActiveHover) cfg.onHover?.(null);
const scrubTarget = isActiveHover ? 1 : 0;
if (noMotion) {
scrubAmount = scrubTarget;
} else {
scrubAmount += (scrubTarget - scrubAmount) * SCRUB_LERP_SPEED;
if (scrubAmount < 0.01) scrubAmount = 0;
if (scrubAmount > 0.99) scrubAmount = 1;
}
// Use last known position during fade-out
let drawHoverX = hoverChartX;
let drawHoverValue = hoverValue;
let drawHoverTime = hoverTime;
if (!isActiveHover && scrubAmount > 0 && lastHover) {
drawHoverX = lastHover.x;
drawHoverValue = lastHover.value;
drawHoverTime = lastHover.time;
}
return {
hoverX: drawHoverX,
hoverValue: drawHoverValue,
hoverTime: drawHoverTime,
scrubAmount,
isActiveHover,
lastHover,
};
}
/** Update badge DOM element — text, width lerp, SVG path, position, color. */
function updateBadgeDOM(
badge: BadgeEls,
cfg: EngineConfig,
smoothValue: number,
layout: ChartLayout,
momentum: Momentum,
badgeY: number | null,
badgeColor: { green: number },
isWindowTransitioning: boolean,
noMotion: boolean,
ctx: CanvasRenderingContext2D,
dt: number,
chartReveal: number = 1,
): number | null /* updated badgeY */ {
if (!cfg.showBadge || chartReveal < 0.25) {
badge.container.style.display = 'none';
return badgeY;
}
badge.container.style.display = '';
const badgeOpacity = chartReveal < 0.5 ? (chartReveal - 0.25) / 0.25 : 1;
badge.container.style.opacity = badgeOpacity < 1 ? String(badgeOpacity) : '';
const { w, h, pad } = layout;
const text = cfg.formatValue(smoothValue);
badge.text.textContent = text;
badge.text.style.font = cfg.palette.labelFont;
badge.text.style.lineHeight = `${BADGE_LINE_H}px`;
const tailLen = cfg.badgeTail ? BADGE_TAIL_LEN : 0;
badge.text.style.padding = `${BADGE_PAD_Y}px ${BADGE_PAD_X}px ${BADGE_PAD_Y}px ${tailLen + BADGE_PAD_X}px`;
// Measure target text width using canvas (template with widest digits)
ctx.font = cfg.palette.labelFont;
const template = text.replace(/[0-9]/g, '8');
const targetTextW = ctx.measureText(template).width;
// Smooth-lerp the badge width
badge.targetW = targetTextW;
if (badge.displayW === 0) badge.displayW = targetTextW;
badge.displayW = lerp(badge.displayW, badge.targetW, BADGE_WIDTH_LERP, dt);
if (Math.abs(badge.displayW - badge.targetW) < 0.3) badge.displayW = badge.targetW;
const textW = badge.displayW;
const pillW = textW + BADGE_PAD_X * 2;
const pillH = BADGE_LINE_H + BADGE_PAD_Y * 2;
const totalW = tailLen + pillW;
badge.svg.setAttribute('width', String(Math.ceil(totalW)));
badge.svg.setAttribute('height', String(pillH));
badge.svg.setAttribute('viewBox', `0 0 ${totalW} ${pillH}`);
badge.path.setAttribute('d', cfg.badgeTail ? badgeSvgPath(pillW, pillH, BADGE_TAIL_LEN, BADGE_TAIL_SPREAD) : badgePillOnly(pillW, pillH));
// Badge Y lerp — decoupled from range/value math, morphed during reveal
const centerY = pad.top + layout.chartH / 2;
const realTargetY = Math.max(pad.top, Math.min(h - pad.bottom, layout.toY(smoothValue)));
const targetBadgeY = chartReveal < 1 ? centerY + (realTargetY - centerY) * chartReveal : realTargetY;
if (badgeY === null || noMotion) {
badgeY = targetBadgeY;
} else {
const badgeSpeed = isWindowTransitioning ? BADGE_Y_LERP_TRANSITIONING : BADGE_Y_LERP;
badgeY = lerp(badgeY, targetBadgeY, badgeSpeed, dt);
}
const badgeLeft = w - pad.right + 8 - BADGE_PAD_X - tailLen;
const badgeTop = badgeY - pillH / 2;
badge.container.style.transform = `translate3d(${badgeLeft}px, ${badgeTop}px, 0)`;
// Badge styling
if (cfg.badgeVariant === 'minimal') {
badge.path.setAttribute('fill', cfg.palette.badgeOuterBg);
badge.text.style.color = cfg.palette.tooltipText;
badge.container.style.filter = `drop-shadow(0 1px 4px ${cfg.palette.badgeOuterShadow})`;
} else {
badge.container.style.filter = '';
badge.text.style.color = '#fff';
const bs = badgeColor;
let fillColor: string;
if (!cfg.showMomentum) {
fillColor = cfg.palette.line;
} else {
const target = momentum === 'up' ? 1 : momentum === 'down' ? 0 : bs.green;
bs.green = noMotion ? target : lerp(bs.green, target, MOMENTUM_COLOR_LERP, dt);
if (bs.green > 0.99) bs.green = 1;
if (bs.green < 0.01) bs.green = 0;
const g = bs.green;
const rr = Math.round(MOMENTUM_RED[0] + (MOMENTUM_GREEN[0] - MOMENTUM_RED[0]) * g);
const gg = Math.round(MOMENTUM_RED[1] + (MOMENTUM_GREEN[1] - MOMENTUM_RED[1]) * g);
const bb = Math.round(MOMENTUM_RED[2] + (MOMENTUM_GREEN[2] - MOMENTUM_RED[2]) * g);
fillColor = `rgb(${rr},${gg},${bb})`;
}
badge.path.setAttribute('fill', fillColor);
}
return badgeY;
}
function updateValueElement(cfg: EngineConfig, value: number, momentum: Momentum): void {
const element = cfg.valueElement;
if (!element) return;
element.textContent = cfg.formatValue(value);
const color = !cfg.valueMomentumColor ? '' : momentum === 'up' ? '#22c55e' : momentum === 'down' ? '#ef4444' : '';
if (color) element.style.color = color;
else element.style.removeProperty('color');
}
// --- Candle-specific helper functions ---
function computeCandleRange(candles: CandlePoint[]): { min: number; max: number } {
let min = Infinity;
let max = -Infinity;
for (const c of candles) {
if (c.low < min) min = c.low;
if (c.high > max) max = c.high;
}
if (!isFinite(min) || !isFinite(max)) return { min: 99, max: 101 };
const range = max - min;
const margin = range * 0.12;
const minRange = range * 0.1 || 0.4;
if (range < minRange) {
const mid = (min + max) / 2;
return { min: mid - minRange / 2, max: mid + minRange / 2 };
}
return { min: min - margin, max: max + margin };
}
function candleAtX(candles: CandlePoint[], hoverX: number, candleWidth: number, layout: ChartLayout): CandlePoint | null {
const time = layout.leftEdge + ((hoverX - layout.pad.left) / layout.chartW) * (layout.rightEdge - layout.leftEdge);
let lo = 0;
let hi = candles.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const c = candles[mid];
if (time < c.time) hi = mid - 1;
else if (time >= c.time + candleWidth) lo = mid + 1;
else return c;
}
return null;
}
/** Smooth Y range for candle mode — adaptive speed, no target tracking. */
function updateCandleRange(
computedRange: { min: number; max: number },
rangeInited: boolean,
displayMin: number,
displayMax: number,
isTransitioning: boolean,
windowTransProgress: number,
wt: { rangeFromMin: number; rangeFromMax: number; rangeToMin: number; rangeToMax: number },
chartH: number,
dt: number,
noMotion: boolean,
): { minVal: number; maxVal: number; valRange: number; displayMin: number; displayMax: number; rangeInited: boolean } {
if (!rangeInited) {
return {
minVal: computedRange.min,
maxVal: computedRange.max,
valRange: computedRange.max - computedRange.min || 0.001,
displayMin: computedRange.min,
displayMax: computedRange.max,
rangeInited: true,
};
}
if (isTransitioning) {
displayMin = wt.rangeFromMin + (wt.rangeToMin - wt.rangeFromMin) * windowTransProgress;
displayMax = wt.rangeFromMax + (wt.rangeToMax - wt.rangeFromMax) * windowTransProgress;
} else {
const curRange = displayMax - displayMin || 1;
const gapMin = Math.abs(displayMin - computedRange.min);
const gapMax = Math.abs(displayMax - computedRange.max);
const gapRatio = Math.min((gapMin + gapMax) / curRange, 1);
const speed = RANGE_LERP_SPEED + (1 - gapRatio) * RANGE_ADAPTIVE_BOOST;
displayMin = noMotion ? computedRange.min : lerp(displayMin, computedRange.min, speed, dt);
displayMax = noMotion ? computedRange.max : lerp(displayMax, computedRange.max, speed, dt);
const pxThreshold = (0.5 * curRange) / chartH || 0.001;
if (Math.abs(displayMin - computedRange.min) < pxThreshold) displayMin = computedRange.min;
if (Math.abs(displayMax - computedRange.max) < pxThreshold) displayMax = computedRange.max;
}
return {
minVal: displayMin,
maxVal: displayMax,
valRange: displayMax - displayMin || 0.001,
displayMin,
displayMax,
rangeInited: true,
};
}
/** Candle window transition — uses candle data instead of line points. */
function updateCandleWindowTransition(
targetWindowSecs: number,
wt: { from: number; to: number; startMs: number; rangeFromMin: number; rangeFromMax: number; rangeToMin: number; rangeToMax: number },
displayWindow: number,
displayMin: number,
displayMax: number,
now_ms: number,
now: number,
candles: CandlePoint[],
liveCandle: CandlePoint | undefined,
candleWidth: number,
buffer: number,
noMotion: boolean,
): { windowSecs: number; windowTransProgress: number } {
if (wt.to !== targetWindowSecs) {
wt.from = displayWindow;
wt.to = targetWindowSecs;
wt.startMs = now_ms;
wt.rangeFromMin = displayMin;
wt.rangeFromMax = displayMax;
const targetRightEdge = now + targetWindowSecs * buffer;
const targetLeftEdge = targetRightEdge - targetWindowSecs;
const targetVisible: CandlePoint[] = [];
for (const c of candles) {
if (c.time + candleWidth >= targetLeftEdge && c.time <= targetRightEdge) {
targetVisible.push(c);
}
}
if (liveCandle && liveCandle.time + candleWidth >= targetLeftEdge && liveCandle.time <= targetRightEdge) {
targetVisible.push(liveCandle);
}
if (targetVisible.length > 0) {
const tr = computeCandleRange(targetVisible);
wt.rangeToMin = tr.min;
wt.rangeToMax = tr.max;
}
}
let windowTransProgress = 0;
let resultWindow: number;
if (noMotion || wt.startMs === 0) {
resultWindow = targetWindowSecs;
wt.startMs = 0;
} else {
const elapsed = now_ms - wt.startMs;
const t = Math.min(elapsed / WINDOW_TRANSITION_MS, 1);
const eased = (1 - Math.cos(t * Math.PI)) / 2;
windowTransProgress = eased;
const logFrom = Math.log(wt.from);
const logTo = Math.log(wt.to);
resultWindow = Math.exp(logFrom + (logTo - logFrom) * eased);
if (t >= 1) {
resultWindow = targetWindowSecs;
wt.startMs = 0;
windowTransProgress = 0;
}
}
return { windowSecs: resultWindow, windowTransProgress };
}
function finitePoint(point: LivelinePoint): boolean {
return Number.isFinite(point.time) && Number.isFinite(point.value);
}
function finiteCandle(candle: CandlePoint): boolean {
return Number.isFinite(candle.time) && Number.isFinite(candle.open) && Number.isFinite(candle.high) && Number.isFinite(candle.low) && Number.isFinite(candle.close);
}
function sanitizeConfig(config: LivelineEngineConfig): LivelineEngineConfig {
const data = config.data.filter(finitePoint);
const value = Number.isFinite(config.value) ? config.value : (data.at(-1)?.value ?? 0);
const windowSecs = Number.isFinite(config.windowSecs) && config.windowSecs > 0 ? config.windowSecs : 1;
const candleWidth = config.candleWidth != null && Number.isFinite(config.candleWidth) && config.candleWidth > 0 ? config.candleWidth : undefined;
return {
...config,
data,
value,
windowSecs,
padding: {
top: Number.isFinite(config.padding.top) ? Math.max(0, config.padding.top) : 0,
right: Number.isFinite(config.padding.right) ? Math.max(0, config.padding.right) : 0,
bottom: Number.isFinite(config.padding.bottom) ? Math.max(0, config.padding.bottom) : 0,
left: Number.isFinite(config.padding.left) ? Math.max(0, config.padding.left) : 0,
},
candleWidth,
candles: config.candles?.filter(finiteCandle),
liveCandle: config.liveCandle && finiteCandle(config.liveCandle) ? config.liveCandle : undefined,
lineData: config.lineData?.filter(finitePoint),
lineValue: Number.isFinite(config.lineValue) ? config.lineValue : undefined,
multiSeries: config.multiSeries?.map((series) => {
const seriesData = series.data.filter(finitePoint);
return {
...series,
data: seriesData,
value: Number.isFinite(series.value) ? series.value : (seriesData.at(-1)?.value ?? 0),
};
}),
};
}
/** Mount the framework-independent Liveline renderer onto adapter-owned elements. */
export function createLivelineEngine(elements: LivelineEngineElements, config: LivelineEngineConfig, environment: LivelineEngineEnvironment = {}): LivelineEngine {
const { canvas, container } = elements;
const doc = environment.document ?? container.ownerDocument;
const requestFrame = environment.requestAnimationFrame ?? doc.defaultView?.requestAnimationFrame?.bind(doc.defaultView);
const cancelFrame = environment.cancelAnimationFrame ?? doc.defaultView?.cancelAnimationFrame?.bind(doc.defaultView);
const performanceNow = environment.performanceNow ?? (() => doc.defaultView?.performance?.now() ?? Date.now());
const epochNow = environment.now ?? Date.now;
const configRef = useRef(sanitizeConfig({ ...config, valueElement: config.valueElement === undefined ? elements.value : config.valueElement }));
config = configRef.current;
let destroyed = false;
let resizeObserver: ResizeObserver | undefined;
let mediaQuery: MediaQueryList | undefined;
const cleanups: Array<() => void> = [];
// Animation state (persistent across frames, no allocations)
const displayValueRef = useRef(config.value);
const displayValuesRef = useRef<Map<string, number>>(new Map());
const seriesAlphaRef = useRef<Map<string, number>>(new Map());
const displayMinRef = useRef(0);
const displayMaxRef = useRef(0);
const targetMinRef = useRef(0);
const targetMaxRef = useRef(0);
const rangeInitedRef = useRef(false);
const displayWindowRef = useRef(config.windowSecs);
const windowTransitionRef = useRef({
from: config.windowSecs,
to: config.windowSecs,
startMs: 0,
rangeFromMin: 0,
rangeFromMax: 0,
rangeToMin: 0,
rangeToMax: 0,
});
const arrowStateRef = useRef({ up: 0, down: 0 });
const gridStateRef = useRef({ interval: 0, labels: new Map<number, number>() }); // value -> alpha
const timeAxisStateRef = useRef({ labels: new Map<number, { alpha: number; text: string }>() });
const orderbookStateRef = useRef(createOrderbookState());
const particleStateRef = useRef(createParticleState());
const shakeStateRef = useRef(createShakeState());
const badgeColorRef = useRef({ green: 1 });
const badgeYRef = useRef<number | null>(null); // lerped badge Y, null = uninited
const reducedMotionRef = useRef(false);
const sizeRef = useRef({ w: 0, h: 0 });
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const rafRef = useRef(0);
const lastFrameRef = useRef<number | null>(null);
// Badge DOM element refs
const badgeRef = useRef<BadgeEls | null>(null);
// Hover state
const hoverXRef = useRef<number | null>(null);
const scrubAmountRef = useRef(0); // 0 = not scrubbing, 1 = fully scrubbing
const lastHoverRef = useRef<{ x: number; value: number; time: number } | null>(null);
const lastHoverEntriesRef = useRef<{ color: string; label: string; value: number }[]>([]);
let hoverActive = false;
const reportHover = (point: HoverPoint | null) => {
if (point === null && !hoverActive) return;
hoverActive = point !== null;
configRef.current.onHover?.(point);
};
// Reveal state (loading → chart morph)
const chartRevealRef = useRef(0); // 0 = loading/empty, 1 = fully revealed
// Pause state
const pauseProgressRef = useRef(0); // 0 = playing, 1 = fully paused
const timeDebtRef = useRef(0); // accumulated seconds behind real time
// Data stash for reverse morph (chart → flat line when data disappears)
const lastDataRef = useRef<LivelinePoint[]>([]);
const lastMultiSeriesRef = useRef<Array<{ id: string; data: LivelinePoint[]; value: number; palette: LivelinePalette; label?: string }>>([]);
const frozenNowRef = useRef(0);
// Pause data snapshot — freeze visible data when pausing to prevent
// consumer-side pruning from eroding the left edge of the line
const pausedDataRef = useRef<LivelinePoint[] | null>(null);
const pausedMultiDataRef = useRef<Map<string, { data: LivelinePoint[]; value: number }> | null>(null);
// Loading ↔ empty crossfade
const loadingAlphaRef = useRef(config.loading ? 1 : 0);
// --- Candle mode refs (only used when mode='candle') ---
const displayCandleRef = useRef<CandlePoint | null>(null);
const liveBirthAlphaRef = useRef(1);
const liveBullRef = useRef(0.5);
const lineSmoothCloseRef = useRef(0);
const lineSmoothInitedRef = useRef(false);
const closeLineSmoothRef = useRef(0); // smooth close for dashed line — never resets on candle birth
const closeLineSmoothInitedRef = useRef(false);
const lineModeProgRef = useRef(0);
const lineModeTransRef = useRef({ startMs: 0, from: 0, to: 0 });
const lineDensityProgRef = useRef(0);
const lineDensityTransRef = useRef({ startMs: 0, from: 0, to: 0 });
const lineTickSmoothRef = useRef(0);
const lineTickSmoothInitedRef = useRef(false);
const candleWidthTransRef = useRef({
fromWidth: config.candleWidth ?? 1,
toWidth: config.candleWidth ?? 1,
startMs: 0,
rangeFromMin: 0,
rangeFromMax: 0,
rangeToMin: 0,
rangeToMax: 0,
oldCandles: [] as CandlePoint[],
oldWidth: config.candleWidth ?? 1,
});
const prevCandleDataRef = useRef({ candles: [] as CandlePoint[], width: config.candleWidth ?? 1 });
const pausedCandlesRef = useRef<CandlePoint[] | null>(null);
const pausedLiveRef = useRef<CandlePoint | null>(null);
const pausedLineDataRef = useRef<LivelinePoint[] | null>(null);
const pausedLineValueRef = useRef<number | null>(null);
const lastCandlesRef = useRef<CandlePoint[]>([]);
const lastLiveRef = useRef<CandlePoint | null>(null);
const lastLineDataStashRef = useRef<LivelinePoint[]>([]);
const lastLineValueStashRef = useRef<number | undefined>(undefined);
// Badge remains engine-owned because its SVG geometry is part of the draw algorithm.
{
const el = doc.createElement('div');
el.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;will-change:transform;display:none;z-index:1;';
const svg = doc.createElementNS(SVG_NS, 'svg');
svg.style.cssText = 'position:absolute;top:0;left:0;';
const path = doc.createElementNS(SVG_NS, 'path');
svg.appendChild(path);
const text = doc.createElement('span');
text.style.cssText = 'position:relative;display:block;color:#fff;white-space:nowrap;';
el.appendChild(svg);
el.appendChild(text);
container.appendChild(el);
badgeRef.current = { container: el, svg, path, text, displayW: 0, targetW: 0 };
cleanups.push(() => {
el.remove();
badgeRef.current = null;
});
}
// ResizeObserver — update size ref without layout thrashing
const ResizeObserverImpl = environment.ResizeObserver ?? doc.defaultView?.ResizeObserver;
if (ResizeObserverImpl) {
resizeObserver = new ResizeObserverImpl((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
sizeRef.current = { w: width, h: height };
});
resizeObserver.observe(container);
cleanups.push(() => resizeObserver?.disconnect());
}
const rect = container.getBoundingClientRect();
sizeRef.current = { w: Math.max(0, rect.width), h: Math.max(0, rect.height) };
// Mouse + touch events for hover/scrub
{
container.style.touchAction = 'pan-y';
const onMove = (e: MouseEvent) => {
if (!configRef.current.scrub) return;
const rect = container.getBoundingClientRect();
const y = e.clientY - rect.top;
const pad = configRef.current.padding;
hoverXRef.current = y < pad.top || y > rect.height - pad.bottom ? null : e.clientX - rect.left;
};
const onLeave = () => {
hoverXRef.current = null;
reportHover(null);
};
let touchStart: { x: number; y: number } | null = null;
let touchAxis: 'pending' | 'horizontal' | 'vertical' = 'pending';
const onTouchStart = (e: TouchEvent) => {
if (!configRef.current.scrub) return;
if (e.touches.length !== 1) return;
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
touchAxis = 'pending';
};
const onTouchMove = (e: TouchEvent) => {
if (!configRef.current.scrub) return;
if (e.touches.length !== 1 || !touchStart) return;
const touch = e.touches[0];
if (touchAxis === 'pending') {
const dx = Math.abs(touch.clientX - touchStart.x);
const dy = Math.abs(touch.clientY - touchStart.y);
if (Math.max(dx, dy) < TOUCH_AXIS_THRESHOLD) return;
touchAxis = dx > dy ? 'horizontal' : 'vertical';
}
if (touchAxis !== 'horizontal') return;
e.preventDefault();
const rect = container.getBoundingClientRect();
const y = touch.clientY - rect.top;
const pad = configRef.current.padding;
hoverXRef.current = y < pad.top || y > rect.height - pad.bottom ? null : touch.clientX - rect.left;
};
const onTouchEnd = () => {
touchStart = null;
touchAxis = 'pending';
hoverXRef.current = null;
reportHover(null);
};
container.addEventListener('mousemove', onMove);
container.addEventListener('mouseleave', onLeave);
container.addEventListener('touchstart', onTouchStart, { passive: true });
container.addEventListener('touchmove', onTouchMove, { passive: false });
container.addEventListener('touchend', onTouchEnd);
container.addEventListener('touchcancel', onTouchEnd);
cleanups.push(() => {
container.removeEventListener('mousemove', onMove);
container.removeEventListener('mouseleave', onLeave);
container.removeEventListener('touchstart', onTouchStart);
container.removeEventListener('touchmove', onTouchMove);
container.removeEventListener('touchend', onTouchEnd);
container.removeEventListener('touchcancel', onTouchEnd);
});
}
// Reduced motion detection
const matchMedia = environment.matchMedia ?? doc.defaultView?.matchMedia?.bind(doc.defaultView);
if (matchMedia) {
mediaQuery = matchMedia('(prefers-reduced-motion: reduce)');
reducedMotionRef.current = mediaQuery.matches;
const onChange = (e: MediaQueryListEvent) => {
reducedMotionRef.current = e.matches;
};
mediaQuery.addEventListener?.('change', onChange);
cleanups.push(() => mediaQuery?.removeEventListener?.('change', onChange));
}
// Pause/resume on visibility change (don't spin rAF when tab is hidden)
const schedule = () => {
if (!destroyed && requestFrame && !rafRef.current) {
rafRef.current = requestFrame(draw);
}
};
const onVisibility = () => {
if (!doc.hidden) schedule();
};
doc.addEventListener('visibilitychange', onVisibility);
cleanups.push(() => doc.removeEventListener('visibilitychange', onVisibility));
// rAF draw loop
function draw() {
rafRef.current = 0;
if (destroyed) return;
if (doc.hidden) {
rafRef.current = 0;
return; // stop the loop; visibilitychange listener will restart it
}
const { w, h } = sizeRef.current;
if (w === 0 || h === 0) {
reportHover(null);
schedule();
return;
}
const cfg = { ...configRef.current, onHover: reportHover };
const dpr = getDpr(environment.devicePixelRatio ?? doc.defaultView?.devicePixelRatio);
// Delta time for frame-rate-independent lerps
const now_ms = performanceNow();
const elapsedMs = lastFrameRef.current !== null ? Math.max(0, now_ms - lastFrameRef.current) : 16.67;
const dt = Math.min(elapsedMs, MAX_DELTA_MS);
lastFrameRef.current = now_ms;
// Resize canvas if needed
const targetW = Math.round(w * dpr);
const targetH = Math.round(h * dpr);
if (canvas.width !== targetW || canvas.height !== targetH) {
canvas.width = targetW;
canvas.height = targetH;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
}
let ctx = ctxRef.current;
if (!ctx || ctx.canvas !== canvas) {
ctx = canvas.getContext('2d');
ctxRef.current = ctx;
}
if (!ctx) {
schedule();
return;
}
applyDpr(ctx, dpr, w, h);
// Reduced motion: use speed=1 to skip all lerps (instant snap)
const noMotion = reducedMotionRef.current;
const animationNow = noMotion ? 0 : now_ms;
// --- Mode-specific pause data snapshot ---
const isCandle = cfg.mode === 'candle';
if (isCandle) {
if (cfg.paused && pausedCandlesRef.current === null && (cfg.candles?.length ?? 0) > 0) {
pausedCandlesRef.current = cfg.candles!.slice();
pausedLiveRef.current = cfg.liveCandle ?? null;
pausedLineDataRef.current = cfg.lineData?.slice() ?? null;
pausedLineValueRef.current = cfg.lineValue ?? null;
}
if (!cfg.paused) {
pausedCandlesRef.current = null;
pausedLiveRef.current = null;
pausedLineDataRef.current = null;
pausedLineValueRef.current = null;
}
} else if (cfg.isMultiSeries && cfg.multiSeries) {
if (cfg.paused && pausedMultiDataRef.current === null) {
const snap = new Map<string, { data: LivelinePoint[]; value: number }>();
for (const s of cfg.multiSeries) {
if (s.data.length >= 2) snap.set(s.id, { data: s.data.slice(), value: s.value });
}
if (snap.size > 0) pausedMultiDataRef.current = snap;
}
if (!cfg.paused) {
pausedMultiDataRef.current = null;
}
} else {
if (cfg.paused && pausedDataRef.current === null && cfg.data.length >= 2) {
pausedDataRef.current = cfg.data.slice();
}
if (!cfg.paused) {
pausedDataRef.current = null;
}
}
const points = isCandle ? ([] as LivelinePoint[]) : (pausedDataRef.current ?? cfg.data);
const effectiveCandles = isCandle ? (pausedCandlesRef.current ?? cfg.candles ?? []) : ([] as CandlePoint[]);
const hasMultiData = cfg.isMultiSeries && cfg.multiSeries ? cfg.multiSeries.some((s) => s.data.length >= 2) : false;
const hasData = isCandle ? effectiveCandles.length > 0 || Boolean(pausedLiveRef.current ?? cfg.liveCandle) : hasMultiData || points.length >= 2;
const hoverPixelX = hasData && !cfg.loading && cfg.scrub ? hoverXRef.current : null;
if (hoverPixelX === null) reportHover(null);
const pad = cfg.padding;
const chartH = h - pad.top - pad.bottom;
if (chartH <= 0 || w - pad.left - pad.right <= 0) {
reportHover(null);
if (badgeRef.current) badgeRef.current.container.style.display = 'none';
schedule();
return;
}
// --- Pause time management ---
const pauseTarget = cfg.paused ? 1 : 0;
pauseProgressRef.current = noMotion ? pauseTarget : lerp(pauseProgressRef.current, pauseTarget, PAUSE_PROGRESS_SPEED, dt);
if (pauseProgressRef.current < 0.005) pauseProgressRef.current = 0;
if (pauseProgressRef.current > 0.995) pauseProgressRef.current = 1;
const pauseProgress = pauseProgressRef.current;
const pausedDt = dt * (1 - pauseProgress);
// Pause debt follows wall time, even when animation steps are capped after a hidden tab.
const realDtSec = elapsedMs / 1000;
timeDebtRef.current += realDtSec * pauseProgress;
// Only drain time debt when unpausing — during pausing, let it
// accumulate freely so the chart decelerates smoothly
if (!cfg.paused && timeDebtRef.current > 0.001) {
const catchUpSpeed = timeDebtRef.current > 10 ? PAUSE_CATCHUP_SPEED_FAST : PAUSE_CATCHUP_SPEED;
timeDebtRef.current = lerp(timeDebtRef.current, 0, catchUpSpeed, dt);
if (timeDebtRef.current < 0.01) timeDebtRef.current = 0;
}
// --- Loading alpha (loading ↔ empty crossfade) ---
const loadingTarget = cfg.loading ? 1 : 0;
loadingAlphaRef.current = noMotion ? loadingTarget : lerp(loadingAlphaRef.current, loadingTarget, LOADING_ALPHA_SPEED, dt);
if (loadingAlphaRef.current < 0.01) loadingAlphaRef.current = 0;
if (loadingAlphaRef.current > 0.99) loadingAlphaRef.current = 1;
const loadingAlpha = loadingAlphaRef.current;
// --- Chart reveal (loading/empty → data morph) ---
const revealTarget = !cfg.loading && hasData ? 1 : 0;
chartRevealRef.current = noMotion ? revealTarget : lerp(chartRevealRef.current, revealTarget, revealTarget === 1 ? CHART_REVEAL_SPEED_FWD : CHART_REVEAL_SPEED, dt);
if (Math.abs(chartRevealRef.current - revealTarget) < 0.005) {
chartRevealRef.current = revealTarget;
}
const chartReveal = chartRevealRef.current;
// Reset range when reveal fully collapses — guarantees a fresh snap
// (not a slow lerp from stale values) when data reappears.
if (chartReveal < 0.01) {
rangeInitedRef.current = false;
}
// Data stash for reverse morph — keep drawing chart while it morphs back
// to the squiggly shape (identical to loading/empty line at reveal=0)
let useStash: boolean;
let useMultiStash = false;
if (isCandle) {
useStash = !hasData && chartReveal > 0.005 && lastCandlesRef.current.length > 0;
// Candle stash updated inside candle pipeline after computing visible
} else {
// Multi-series stash
useMultiStash = !hasData && chartReveal > 0.005 && lastMultiSeriesRef.current.length > 0;
if (hasMultiData && cfg.multiSeries) {
lastMultiSeriesRef.current = cfg.multiSeries.map((s) => ({
id: s.id,
data: s.data.slice(),
value: s.value,
palette: s.palette,
label: s.label,
}));
}
// Clear multi stash when single-series data arrives
if (hasData && !cfg.isMultiSeries) lastMultiSeriesRef.current = [];
useStash = !useMultiStash && !hasData && chartReveal > 0.005 && lastDataRef.current.length >= 2;
if (hasData && !cfg.isMultiSeries) lastDataRef.current = points;
}
// Update lineModeProg even during early return — prevents the
// transition from freezing when the user toggles lineMode while
// in loading or empty state. Without this, lineModeProg stays at
// its pre-loading value and causes an accent-colored line flash
// when data arrives (BUG #3).
if (isCandle) {
const lmt = lineModeTransRef.current;
const lineModeTarget = cfg.lineMode ? 1 : 0;
if (lmt.to !== lineModeTarget) {
lmt.from = lineModeProgRef.current;
lmt.to = lineModeTarget;
lmt.startMs = now_ms;
}
if (noMotion) {
lineModeProgRef.current = lineModeTarget;
lmt.startMs = 0;
} else if (lmt.startMs > 0) {
const elapsed = now_ms - lmt.startMs;
const t = Math.min(elapsed / LINE_MORPH_MS, 1);
lineModeProgRef.current = lmt.from + (lmt.to - lmt.from) * ((1 - Math.cos(t * Math.PI)) / 2);
if (t >= 1) {
lineModeProgRef.current = lmt.to;
lmt.startMs = 0;
}
} else {
lineModeProgRef.current = lmt.to;
}
}
if (!hasData && !useStash && !useMultiStash) {
// No chart pipeline — draw loading or empty as the sole visual.
// Grey loading line for candle mode and multi-series (no single accent color)
const loadingColor = isCandle || cfg.isMultiSeries || lastMultiSeriesRef.current.length > 0 ? cfg.palette.gridLabel : undefined;
if (loadingAlpha > 0.01) {
drawLoading(ctx, w, h, pad, cfg.palette, animationNow, loadingAlpha, loadingColor);
}
if (1 - loadingAlpha > 0.01) {
drawEmpty(ctx, w, h, pad, cfg.palette, 1 - loadingAlpha, animationNow, false, cfg.emptyText);
}
// Left-edge fade
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const fadeGrad = ctx.createLinearGradient(pad.left, 0, pad.left + FADE_EDGE_WIDTH, 0);
fadeGrad.addColorStop(0, 'rgba(0, 0, 0, 1)');
fadeGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = fadeGrad;
ctx.fillRect(0, 0, pad.left + FADE_EDGE_WIDTH, h);
ctx.restore();
if (badgeRef.current) badgeRef.current.container.style.display = 'none';
schedule();
return;
}
if (isCandle) {
// ═══════════════════════════════════════════════════════
// 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 = epochNow() / 1000 - timeDebtRef.current;
const now = hasData || chartReveal < 0.005 ? epochNow() / 1000 - timeDebtRef.current : frozenNowRef.current;
const rawLive = pausedCandlesRef.current ? (pausedLiveRef.current ?? undefined) : cfg.liveCandle;
let effectiveLineData = pausedLineDataRef.current ?? cfg.lineData;
let effectiveLineValue = pausedLineValueRef.current ?? cfg.lineValue;
// Stash tick data for reverse morph — keeps tick resolution during morphback
if (hasData && effectiveLineData && effectiveLineData.length > 0) {
lastLineDataStashRef.current = effectiveLineData;
lastLineValueStashRef.current = effectiveLineValue;
}
if (useStash && lastLineDataStashRef.current.length > 0) {
effectiveLineData = lastLineDataStashRef.current;
effectiveLineValue = lastLineValueStashRef.current;
}
const candleWidthSecs = cfg.candleWidth ?? 1;
// --- Candle width morph transition ---
const cwt = candleWidthTransRef.current;
let morphT = -1;
let displayCandleWidth: number;
if (noMotion) {
displayCandleWidth = candleWidthSecs;
cwt.toWidth = candleWidthSecs;
cwt.startMs = 0;
} else if (cwt.startMs > 0) {
const elapsed = now_ms - cwt.startMs;
const t = Math.min(elapsed / CANDLE_WIDTH_TRANS_MS, 1);
morphT = (1 - Math.cos(t * Math.PI)) / 2;
displayCandleWidth = Math.exp(Math.log(cwt.fromWidth) + (Math.log(cwt.toWidth) - Math.log(cwt.fromWidth)) * morphT);
if (t >= 1) {
displayCandleWidth = cwt.toWidth;
cwt.startMs = 0;
morphT = -1;
}
} else {
displayCandleWidth = cwt.toWidth;
}
if (candleWidthSecs !== cwt.toWidth) {
cwt.oldCandles = prevCandleDataRef.current.candles;
cwt.oldWidth = prevCandleDataRef.current.width;
cwt.fromWidth = displayCandleWidth;
cwt.toWidth = candleWidthSecs;
cwt.startMs = now_ms;
morphT = 0;
cwt.rangeFromMin = displayMinRef.current;
cwt.rangeFromMax = displayMaxRef.current;
const curWindow = displayWindowRef.current;
const re = now + curWindow * candleBuffer;
const le = re - curWindow;
const targetVis: CandlePoint[] = [];
for (const c of effectiveCandles) {
if (c.time + candleWidthSecs >= le && c.time <= re) targetVis.push(c);
}
if (rawLive) targetVis.push(rawLive);
if (targetVis.length > 0) {
const tr = computeCandleRange(targetVis);
cwt.rangeToMin = tr.min;
cwt.rangeToMax = tr.max;
} else {
cwt.rangeToMin = displayMinRef.current;
cwt.rangeToMax = displayMaxRef.current;
}
}
prevCandleDataRef.current = { candles: cfg.candles ?? [], width: candleWidthSecs };
// lineModeProg is updated before the early return (see above).
const lineModeProg = lineModeProgRef.current;
// --- Line density transition ---
const ldt = lineDensityTransRef.current;
const hasTickData = effectiveLineData && effectiveLineData.length > 0;
const densityTarget = cfg.lineMode && lineModeProg >= 0.3 && hasTickData ? 1 : 0;
if (ldt.to !== densityTarget) {
ldt.from = lineDensityProgRef.current;
ldt.to = densityTarget;
ldt.startMs = now_ms;
}
let lineDensityProg: number;
if (noMotion) {
lineDensityProg = densityTarget;
ldt.startMs = 0;
} else if (ldt.startMs > 0) {
const elapsed = now_ms - ldt.startMs;
const t = Math.min(elapsed / LINE_DENSITY_MS, 1);
lineDensityProg = ldt.from + (ldt.to - ldt.from) * (1 - (1 - t) * (1 - t));
if (t >= 1) {
lineDensityProg = ldt.to;
ldt.startMs = 0;
}
} else {
lineDensityProg = ldt.to;
}
lineDensityProgRef.current = lineDensityProg;
// --- Window transition ---
const transition = windowTransitionRef.current;
const windowResult = updateCandleWindowTransition(
cfg.windowSecs,
transition,
displayWindowRef.current,
displayMinRef.current,
displayMaxRef.current,
now_ms,
now,
effectiveCandles,
rawLive,
candleWidthSecs,
candleBuffer,
noMotion,
);
displayWindowRef.current = windowResult.windowSecs;
const windowSecs = windowResult.windowSecs;
const windowTransProgress = windowResult.windowTransProgress;
const isWindowTransitioning = transition.startMs > 0;
const rightEdge = now + windowSecs * candleBuffer;
const leftEdge = rightEdge - windowSecs;
// --- Live candle OHLC lerp ---
let smoothLive: CandlePoint | undefined;
if (rawLive) {
const prev = displayCandleRef.current;
if (!prev || prev.time !== rawLive.time) {
displayCandleRef.current = {
time: rawLive.time,
open: rawLive.open,
high: rawLive.open,
low: rawLive.open,
close: rawLive.open,
};
liveBirthAlphaRef.current = 0;
} else {
const dc = displayCandleRef.current!;
dc.open = noMotion ? rawLive.open : lerp(dc.open, rawLive.open, CANDLE_LERP_SPEED, pausedDt);
dc.high = noMotion ? rawLive.high : lerp(dc.high, rawLive.high, CANDLE_LERP_SPEED, pausedDt);
dc.low = noMotion ? rawLive.low : lerp(dc.low, rawLive.low, CANDLE_LERP_SPEED, pausedDt);
dc.close = noMotion ? rawLive.close : lerp(dc.close, rawLive.close, CANDLE_LERP_SPEED, pausedDt);
}
liveBirthAlphaRef.current = noMotion ? 1 : lerp(liveBirthAlphaRef.current, 1, 0.2, pausedDt);
if (liveBirthAlphaRef.current > 0.99) liveBirthAlphaRef.current = 1;
const dc = displayCandleRef.current!;
const bullTarget = dc.close >= dc.open ? 1 : 0;
liveBullRef.current = noMotion ? bullTarget : lerp(liveBullRef.current, bullTarget, 0.12, pausedDt);
if (liveBullRef.current > 0.99) liveBullRef.current = 1;
if (liveBullRef.current < 0.01) liveBullRef.current = 0;
smoothLive = dc;
} else {
displayCandleRef.current = null;
liveBirthAlphaRef.current = 1;
liveBullRef.current = 0.5;
}
// --- Smooth close for dashed price line ---
// Tracks rawLive.close at candle-body speed but never resets on candle
// birth, so the dashed line doesn't jump when a new candle starts.
if (rawLive) {
if (!closeLineSmoothInitedRef.current) {
closeLineSmoothRef.current = rawLive.close;
closeLineSmoothInitedRef.current = true;
} else {
closeLineSmoothRef.current = noMotion ? rawLive.close : lerp(closeLineSmoothRef.current, rawLive.close, CLOSE_LINE_LERP_SPEED, pausedDt);
const gap = Math.abs(closeLineSmoothRef.current - rawLive.close);
const range = displayMaxRef.current - displayMinRef.current || 1;
if (gap < range * 0.0005) closeLineSmoothRef.current = rawLive.close;
}
} else if (!useStash) {
closeLineSmoothInitedRef.current = false;
}
// --- Smooth close for line mode ---
if (rawLive) {
if (!lineSmoothInitedRef.current) {
lineSmoothCloseRef.current = rawLive.close;
lineSmoothInitedRef.current = true;
} else {
const valGap = Math.abs(rawLive.close - lineSmoothCloseRef.current);
const prevRange = displayMaxRef.current - displayMinRef.current || 1;
const gapRatio = Math.min(valGap / prevRange, 1);
const adaptiveSpeed = LINE_LERP_BASE + (1 - gapRatio) * LINE_ADAPTIVE_BOOST;
lineSmoothCloseRef.current = noMotion ? rawLive.close : lerp(lineSmoothCloseRef.current, rawLive.close, adaptiveSpeed, pausedDt);
if (valGap < prevRange * LINE_SNAP_THRESHOLD) lineSmoothCloseRef.current = rawLive.close;
}
} else if (!useStash) {
// Only reset when not using stash — during reverse morph,
// freeze the smooth value (matches line mode's displayValueRef freeze)
lineSmoothInitedRef.current = false;
}
// --- Smooth tick value for density transition ---
if (effectiveLineValue !== undefined && hasTickData) {
if (!lineTickSmoothInitedRef.current) {
lineTickSmoothRef.current = effectiveLineValue;
lineTickSmoothInitedRef.current = true;
} else {
const valGap = Math.abs(effectiveLineValue - lineTickSmoothRef.current);
const prevRange = displayMaxRef.current - displayMinRef.current || 1;
const gapRatio = Math.min(valGap / prevRange, 1);
const adaptiveSpeed = LINE_LERP_BASE + (1 - gapRatio) * LINE_ADAPTIVE_BOOST;
lineTickSmoothRef.current = noMotion ? effectiveLineValue : lerp(lineTickSmoothRef.current, effectiveLineValue, adaptiveSpeed, pausedDt);
if (valGap < prevRange * LINE_SNAP_THRESHOLD) lineTickSmoothRef.current = effectiveLineValue;
}
} else if (!useStash) {
lineTickSmoothInitedRef.current = false;
}
// --- Build visible candles ---
const visible: CandlePoint[] = [];
for (const c of effectiveCandles) {
if (c.time + candleWidthSecs >= leftEdge && c.time <= rightEdge) visible.push(c);
}
if (smoothLive && smoothLive.time + displayCandleWidth >= leftEdge && smoothLive.time <= rightEdge) {
visible.push(smoothLive);
}
let oldVisible: CandlePoint[] = [];
if (morphT >= 0 && cwt.oldCandles.length > 0) {
for (const c of cwt.oldCandles) {
if (c.time + cwt.oldWidth >= leftEdge && c.time <= rightEdge) oldVisible.push(c);
}
}
// Stash visible candles for reverse morph
if (hasData) {
lastCandlesRef.current = visible;
lastLiveRef.current = smoothLive ?? null;
}
const effectiveVisible = useStash ? lastCandlesRef.current : visible;
const effectiveLive = useStash ? (lastLiveRef.current ?? undefined) : smoothLive;
if (effectiveVisible.length === 0) {
reportHover(null);
if (loadingAlpha > 0.01) drawLoading(ctx, w, h, pad, cfg.palette, animationNow, loadingAlpha, cfg.palette.gridLabel);
if (1 - loadingAlpha > 0.01) drawEmpty(ctx, w, h, pad, cfg.palette, 1 - loadingAlpha, animationNow, false, cfg.emptyText);
if (badgeRef.current) badgeRef.current.container.style.display = 'none';
schedule();
return;
}
// --- Range computation ---
// Always use full OHLC range regardless of line mode progress.
// The close-only and tick-level ranges are tighter (no wicks),
// so blending between them during morphs shifts the Y axis and
// causes visible grid label drift + line position jumps.
// Using one consistent OHLC range means zero range change during
// the morph — the line gets slightly more Y margin in line mode
// (room for wicks it doesn't use) but that's an acceptable trade-off.
const chartW = w - pad.left - pad.right;
const computed = effectiveVisible.length > 0 ? computeCandleRange(effectiveVisible) : { min: displayMinRef.current, max: displayMaxRef.current };
const rangeResult = updateCandleRange(
computed,
rangeInitedRef.current,
displayMinRef.current,
displayMaxRef.current,
isWindowTransitioning,
windowTransProgress,
transition,
chartH,
pausedDt,
noMotion,
);
if (morphT >= 0) {
rangeResult.displayMin = cwt.rangeFromMin + (cwt.rangeToMin - cwt.rangeFromMin) * morphT;
rangeResult.displayMax = cwt.rangeFromMax + (cwt.rangeToMax - cwt.rangeFromMax) * morphT;
rangeResult.minVal = rangeResult.displayMin;
rangeResult.maxVal = rangeResult.displayMax;
rangeResult.valRange = rangeResult.displayMax - rangeResult.displayMin || 0.001;
}
rangeInitedRef.current = rangeResult.rangeInited;
displayMinRef.current = rangeResult.displayMin;
displayMaxRef.current = rangeResult.displayMax;
const { minVal, maxVal, valRange } = rangeResult;
const layout: ChartLayout = {
w,
h,
pad,
chartW,
chartH,
leftEdge,
rightEdge,
minVal,
maxVal,
valRange,
toX: (t: number) => pad.left + ((t - leftEdge) / (rightEdge - leftEdge)) * chartW,
toY: (v: number) => pad.top + (1 - (v - minVal) / valRange) * chartH,
};
// --- Hover + scrub ---
const hoverPx = hoverPixelX;
let hoveredCandle: CandlePoint | null = null;
let isActiveHover = false;
if (lineModeProg <= 0.5 && hoverPx !== null && hoverPx >= pad.left && hoverPx <= w - pad.right) {
hoveredCandle = candleAtX(effectiveVisible, hoverPx, displayCandleWidth, layout);
if (hoveredCandle) isActiveHover = true;
}
const scrubTarget = isActiveHover ? 1 : 0;
if (lineModeProg <= 0.5) scrubAmountRef.current = noMotion ? scrubTarget : lerp(scrubAmountRef.current, scrubTarget, 0.12, dt);
if (scrubAmountRef.current < 0.01) scrubAmountRef.current = 0;
if (scrubAmountRef.current > 0.99) scrubAmountRef.current = 1;
let scrubAmount = scrubAmountRef.current;
let drawHoverX = hoverPx;
let drawHoverTime = 0;
let drawHoverCandle: CandlePoint | null = hoveredCandle;
if (!isActiveHover && scrubAmount > 0 && lastHoverRef.current) {
drawHoverX = lastHoverRef.current.x;
drawHoverTime = lastHoverRef.current.time;
drawHoverCandle = candleAtX(effectiveVisible, lastHoverRef.current.x, displayCandleWidth, layout);
} else if (isActiveHover && hoverPx !== null) {
drawHoverTime = layout.leftEdge + ((hoverPx - pad.left) / chartW) * (layout.rightEdge - layout.leftEdge);
lastHoverRef.current = { x: hoverPx, value: hoveredCandle?.close ?? 0, time: drawHoverTime };
cfg.onHover?.({
time: drawHoverTime,
value: hoveredCandle?.close ?? 0,
x: hoverPx,
y: layout.toY(hoveredCandle?.close ?? 0),
});
}
if (lineModeProg <= 0.5 && !isActiveHover) reportHover(null);
let drawCandles = effectiveVisible;
let drawOldCandles = oldVisible;
let drawLive = effectiveLive;
// Line mode: blend live close toward smooth close
if (lineModeProg > 0.01 && drawLive && lineSmoothInitedRef.current) {
const blended = drawLive.close + (lineSmoothCloseRef.current - drawLive.close) * lineModeProg;
drawLive = { ...drawLive, close: blended };
const li = drawCandles.length - 1;
if (li >= 0 && drawCandles[li].time === drawLive.time) {
drawCandles = drawCandles.slice();
drawCandles[li] = { ...drawCandles[li], close: blended };
}
}
// Line mode OHLC collapse
if (lineModeProg > 0.01 && lineModeProg < 0.99) {
const collapseOHLC = (c: CandlePoint): CandlePoint => {
const inv = 1 - lineModeProg;
return {
time: c.time,
open: c.close + (c.open - c.close) * inv,
high: c.close + (c.high - c.close) * inv,
low: c.close + (c.low - c.close) * inv,
close: c.close,
};
};
drawCandles = drawCandles.map(collapseOHLC);
if (drawOldCandles.length > 0) drawOldCandles = drawOldCandles.map(collapseOHLC);
if (drawLive) drawLive = collapseOHLC(drawLive);
}
// Build lineVisible for drawLine — value-space points that drawLine
// converts to screen coords with its own morphY/alpha/color logic.
// Use tick-level resolution whenever the line is visible (lineModeProg > 0.05),
// not just when lineDensityProg > 0.01. The density transition finishes
// 150ms before the line fades out; without this, lineVisible abruptly drops
// from ~300 smooth points to ~5 stepped candle-close points while the line
// is still at ~30% opacity, causing a visible shape jump.
let lineVisible: LivelinePoint[];
let lineSmoothValue: number;
if (effectiveLineData && effectiveLineData.length > 0 && (lineDensityProg > 0.01 || lineModeProg > 0.05)) {
// Density transition: blend candle-close values toward tick values
const closeRefs: { t: number; v: number }[] = [];
for (const c of drawCandles) {
closeRefs.push({ t: c.time + displayCandleWidth / 2, v: c.close });
}
if (drawLive) closeRefs.push({ t: now, v: drawLive.close });
lineVisible = [];
let refIdx = 0;
for (const pt of effectiveLineData) {
if (pt.time < leftEdge || pt.time > rightEdge) continue;
while (refIdx < closeRefs.length - 2 && closeRefs[refIdx + 1].t < pt.time) refIdx++;
let interpClose: number;
if (closeRefs.length === 0) {
interpClose = pt.value;
} else if (closeRefs.length === 1 || pt.time <= closeRefs[0].t) {
interpClose = closeRefs[0].v;
} else if (refIdx >= closeRefs.length - 1) {
interpClose = closeRefs[closeRefs.length - 1].v;
} else {
const a = closeRefs[refIdx];
const b = closeRefs[refIdx + 1];
const span = b.t - a.t;
const frac = span > 0 ? Math.max(0, Math.min(1, (pt.time - a.t) / span)) : 0;
interpClose = a.v + (b.v - a.v) * frac;
}
const blended = interpClose + (pt.value - interpClose) * lineDensityProg;
lineVisible.push({ time: pt.time, value: blended });
}
const smoothTick = lineTickSmoothInitedRef.current ? lineTickSmoothRef.current : (effectiveLineValue ?? effectiveLineData[effectiveLineData.length - 1].value);
// No explicit live tip — drawLine appends one at toX(now) using lineSmoothValue
lineSmoothValue = lineSmoothCloseRef.current + (smoothTick - lineSmoothCloseRef.current) * lineDensityProg;
} else {
// Candle-close resolution — no live tip; drawLine appends one at toX(now)
lineVisible = drawCandles.map((c) => ({
time: c.time + displayCandleWidth / 2,
value: c.close,
}));
lineSmoothValue = lineSmoothInitedRef.current ? lineSmoothCloseRef.current : (drawLive?.close ?? drawCandles[drawCandles.length - 1]?.close ?? 0);
}
let drawHoverValue = drawHoverCandle?.close ?? null;
if (lineModeProg > 0.5) {
// Match the density-morphed line and its live tip, not the candle close.
const hover = updateHoverState(
hoverPx,
pad,
w,
layout,
now,
[...lineVisible, { time: now, value: lineSmoothValue }],
scrubAmount,
lastHoverRef.current,
cfg,
noMotion,
leftEdge,
rightEdge,
chartW,
);
scrubAmount = scrubAmountRef.current = hover.scrubAmount;
lastHoverRef.current = hover.lastHover;
drawHoverX = hover.hoverX;
drawHoverTime = hover.hoverTime ?? 0;
drawHoverValue = hover.hoverValue;
}
// Pad lineVisible to span full chart width during reveal morph.
// Without this, data that doesn't fill the window creates a partial-width
// line that pops when it hands off to the full-width loading squiggly.
if (chartReveal < 1 && lineVisible.length >= 2) {
const firstTime = lineVisible[0].time;
const windowSpan = rightEdge - leftEdge;
if (firstTime - leftEdge > windowSpan * 0.05) {
const firstVal = lineVisible[0].value;
const step = windowSpan / 32;
const padded: LivelinePoint[] = [];
for (let t = leftEdge; t < firstTime - step * 0.5; t += step) {
padded.push({ time: t, value: firstVal });
}
lineVisible = [...padded, ...lineVisible];
}
}
// --- Draw ---
drawCandleFrame(ctx, layout, cfg.palette, {
candles: drawCandles,
displayCandleWidth,
oldCandles: drawOldCandles,
oldWidth: cwt.oldWidth,
morphT,
liveCandle: drawLive,
closePriceCandle: closeLineSmoothInitedRef.current && rawLive ? { ...rawLive, close: closeLineSmoothRef.current } : rawLive,
liveTime: effectiveLive?.time ?? -1,
liveBirthAlpha: liveBirthAlphaRef.current,
liveBullBlend: liveBullRef.current,
lineModeProg,
chartReveal,
now_ms: animationNow,
now,
pauseProgress,
showGrid: cfg.showGrid,
showPulse: cfg.showPulse,
scrubAmount,
hoverX: drawHoverX,
hoverValue: drawHoverValue,
hoverTime: drawHoverTime,
hoveredCandle: drawHoverCandle,
formatValue: cfg.formatValue,
formatTime: cfg.formatTime,
gridState: gridStateRef.current,
timeAxisState: timeAxisStateRef.current,
dt: pausedDt,
targetWindowSecs: cfg.windowSecs,
tooltipY: cfg.tooltipY,
tooltipOutline: cfg.tooltipOutline,
lineVisible,
lineSmoothValue,
emptyText: cfg.emptyText,
loadingAlpha,
// Show empty overlay when not loading AND loadingAlpha has fully
// decayed. This prevents the gradient gap from flashing during
// 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,
reducedMotion: noMotion,
});
// Badge in candle mode — only when in line mode (lineModeProg > 0.5)
if (badgeRef.current) {
if (lineModeProg > 0.5 && cfg.showBadge) {
const momentum = detectMomentum(lineVisible);
badgeYRef.current = updateBadgeDOM(
badgeRef.current,
cfg,
lineSmoothValue,
layout,
momentum,
badgeYRef.current,
badgeColorRef.current,
isWindowTransitioning,
noMotion,
ctx,
pausedDt,
chartReveal,
);
// Fade badge in/out with lineModeProg (0.5→1 maps to 0→1)
const badgeFade = (lineModeProg - 0.5) * 2;
if (badgeRef.current.container.style.display !== 'none') {
const base = badgeRef.current.container.style.opacity ? parseFloat(badgeRef.current.container.style.opacity) : 1;
badgeRef.current.container.style.opacity = String(base * badgeFade * (1 - pauseProgress));
}
} else {
badgeRef.current.container.style.display = 'none';
}
}
updateValueElement(cfg, lineSmoothValue, detectMomentum(lineVisible));
} else if ((cfg.isMultiSeries && cfg.multiSeries && cfg.multiSeries.length > 0) || useMultiStash) {
// ═══════════════════════════════════════════════════════
// MULTI-SERIES LINE MODE PIPELINE
// ═══════════════════════════════════════════════════════
const effectiveMultiSeries = useMultiStash ? lastMultiSeriesRef.current : cfg.multiSeries!;
// Reserve just enough right-side space so endpoint labels don't overlap
// grid value text (which starts at w - pad.right + 8). Labels are drawn
// at lineEnd + 6, so overlap = labelW + 6 - 8 = labelW - 2.
// Scale with chartReveal so layout doesn't shift during loading collapse.
let labelReserve = 0;
if (effectiveMultiSeries.some((s) => s.label)) {
ctx.font = '600 10px -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
let maxLabelW = 0;
for (const s of effectiveMultiSeries) {
if (s.label) {
const lw = ctx.measureText(s.label).width;
if (lw > maxLabelW) maxLabelW = lw;
}
}
// Long labels must leave usable space for the plot.
labelReserve = Math.min(Math.max(0, maxLabelW - 2), (w - pad.left - pad.right) / 2) * chartReveal;
}
const chartW = w - pad.left - pad.right - labelReserve;
const buffer = cfg.showBadge ? WINDOW_BUFFER : WINDOW_BUFFER_NO_BADGE;
// Clean stale entries from displayValuesRef (series that were removed)
if (!useMultiStash) {
const currentIds = new Set(effectiveMultiSeries.map((s) => s.id));
for (const key of displayValuesRef.current.keys()) {
if (!currentIds.has(key)) displayValuesRef.current.delete(key);
}
}
// Use first series data for window transition seeding
const firstSeries = effectiveMultiSeries[0];
const transition = windowTransitionRef.current;
if (hasData) frozenNowRef.current = epochNow() / 1000 - timeDebtRef.current;
const now = useMultiStash ? frozenNowRef.current : epochNow() / 1000 - timeDebtRef.current;
// Per-series smooth values (freeze when using stash)
const smoothValues = new Map<string, number>();
for (const s of effectiveMultiSeries) {
let dv = displayValuesRef.current.get(s.id);
if (dv === undefined) dv = s.value;
if (!useMultiStash) {
const adaptiveSpeed = computeAdaptiveSpeed(s.value, dv, displayMinRef.current, displayMaxRef.current, cfg.lerpSpeed, noMotion);
dv = lerp(dv, s.value, adaptiveSpeed, pausedDt);
const prevRange = displayMaxRef.current - displayMinRef.current || 1;
if (Math.abs(dv - s.value) < prevRange * VALUE_SNAP_THRESHOLD) dv = s.value;
displayValuesRef.current.set(s.id, dv);
}
smoothValues.set(s.id, dv);
}
// Per-series visibility alpha (lerp toward 0 for hidden, 1 for visible)
const hiddenIds = cfg.hiddenSeriesIds;
const seriesAlphas = seriesAlphaRef.current;
for (const s of effectiveMultiSeries) {
let alpha = seriesAlphas.get(s.id) ?? 1;
const target = hiddenIds?.has(s.id) ? 0 : 1;
alpha = noMotion ? target : lerp(alpha, target, SERIES_TOGGLE_SPEED, pausedDt);
if (alpha < 0.01) alpha = 0;
if (alpha > 0.99) alpha = 1;
seriesAlphas.set(s.id, alpha);
}
// Window transition — seed with all series data for accurate range
const firstData = pausedMultiDataRef.current?.get(firstSeries.id)?.data ?? firstSeries.data;
const windowResult = updateWindowTransition(
cfg,
transition,
displayWindowRef.current,
displayMinRef.current,
displayMaxRef.current,
noMotion,
now_ms,
now,
firstData,
smoothValues.get(firstSeries.id) ?? firstSeries.value,
buffer,
);
// Override range target with union of ALL series (not just first)
if (transition.startMs > 0 && effectiveMultiSeries.length > 1) {
const targetRightEdge = now + cfg.windowSecs * buffer;
const targetLeftEdge = targetRightEdge - cfg.windowSecs;
let unionMin = Infinity;
let unionMax = -Infinity;
for (const s of effectiveMultiSeries) {
const sData = pausedMultiDataRef.current?.get(s.id)?.data ?? s.data;
const sv = smoothValues.get(s.id) ?? s.value;
const targetVisible: LivelinePoint[] = [];
for (const p of sData) {
if (p.time >= targetLeftEdge - 2 && p.time <= targetRightEdge) targetVisible.push(p);
}
if (targetVisible.length > 0) {
const range = computeRange(targetVisible, sv, cfg.referenceLine?.value, cfg.exaggerate);
if (range.min < unionMin) unionMin = range.min;
if (range.max > unionMax) unionMax = range.max;
}
}
if (isFinite(unionMin) && isFinite(unionMax)) {
transition.rangeToMin = unionMin;
transition.rangeToMax = unionMax;
}
}
displayWindowRef.current = windowResult.windowSecs;
const windowSecs = windowResult.windowSecs;
const windowTransProgress = windowResult.windowTransProgress;
const isWindowTransitioning = transition.startMs > 0;
const rightEdge = now + windowSecs * buffer;
const leftEdge = rightEdge - windowSecs;
const filterRight = rightEdge - (rightEdge - now) * pauseProgress;
// Build per-series visible arrays and compute global range
// Use paused snapshots when available to prevent left-edge erosion
// Exclude hidden series (alpha < 0.01) from range so Y-axis adjusts
const seriesEntries: MultiSeriesEntry[] = [];
let firstVisibleSeries: (typeof effectiveMultiSeries)[number] | undefined;
let globalMin = Infinity;
let globalMax = -Infinity;
for (const s of effectiveMultiSeries) {
const snap = pausedMultiDataRef.current?.get(s.id);
const seriesData = snap?.data ?? s.data;
const visible: LivelinePoint[] = [];
for (const p of seriesData) {
if (p.time >= leftEdge - 2 && p.time <= filterRight) visible.push(p);
}
const sv = smoothValues.get(s.id) ?? s.value;
const alpha = seriesAlphas.get(s.id) ?? 1;
if (visible.length >= 2) {
if (!firstVisibleSeries && !hiddenIds?.has(s.id)) firstVisibleSeries = s;
// Only include in range if series is at least partially visible
if (alpha > 0.01) {
const range = computeRange(visible, sv, cfg.referenceLine?.value, cfg.exaggerate);
if (range.min < globalMin) globalMin = range.min;
if (range.max > globalMax) globalMax = range.max;
}
// Always push to entries (drawMultiFrame skips via alpha)
seriesEntries.push({ visible, smoothValue: sv, palette: s.palette, label: s.label, alpha });
}
}
if (seriesEntries.length === 0) {
reportHover(null);
// No visible data — draw loading/empty fallback (matching single-series behavior)
// Grey loading line for multi-series (no single accent color to use)
if (loadingAlpha > 0.01) {
drawLoading(ctx, w, h, pad, cfg.palette, animationNow, loadingAlpha, cfg.palette.gridLabel);
}
if (1 - loadingAlpha > 0.01) {
drawEmpty(ctx, w, h, pad, cfg.palette, 1 - loadingAlpha, animationNow, false, cfg.emptyText);
}
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const fadeGrad = ctx.createLinearGradient(pad.left, 0, pad.left + FADE_EDGE_WIDTH, 0);
fadeGrad.addColorStop(0, 'rgba(0, 0, 0, 1)');
fadeGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = fadeGrad;
ctx.fillRect(0, 0, pad.left + FADE_EDGE_WIDTH, h);
ctx.restore();
if (badgeRef.current) badgeRef.current.container.style.display = 'none';
schedule();
return;
}
// Smooth global range
const computedRange = { min: isFinite(globalMin) ? globalMin : 0, max: isFinite(globalMax) ? globalMax : 1 };
const adaptiveSpeed = cfg.lerpSpeed + ADAPTIVE_SPEED_BOOST * 0.5;
const rangeResult = updateRange(
computedRange,
rangeInitedRef.current,
targetMinRef.current,
targetMaxRef.current,
displayMinRef.current,
displayMaxRef.current,
isWindowTransitioning,
windowTransProgress,
transition,
adaptiveSpeed,
chartH,
pausedDt,
);
rangeInitedRef.current = rangeResult.rangeInited;
targetMinRef.current = rangeResult.targetMin;
targetMaxRef.current = rangeResult.targetMax;
displayMinRef.current = rangeResult.displayMin;
displayMaxRef.current = rangeResult.displayMax;
const { minVal, maxVal, valRange } = rangeResult;
const layout: ChartLayout = {
w,
h,
pad,
chartW,
chartH,
leftEdge,
rightEdge,
minVal,
maxVal,
valRange,
toX: (t: number) => pad.left + ((t - leftEdge) / (rightEdge - leftEdge)) * chartW,
toY: (v: number) => pad.top + (1 - (v - minVal) / valRange) * chartH,
};
// Hover — interpolate value at hover time for each series
const hoverPx = hoverPixelX;
let drawHoverX: number | null = null;
let drawHoverTime: number | null = null;
let isActiveHover = false;
let hoverEntries: { color: string; label: string; value: number }[] = [];
if (hoverPx !== null && hoverPx >= pad.left && hoverPx <= w - pad.right) {
const maxHoverX = layout.toX(now);
const clampedX = Math.min(hoverPx, maxHoverX);
const t = leftEdge + ((clampedX - pad.left) / chartW) * (rightEdge - leftEdge);
drawHoverX = clampedX;
drawHoverTime = t;
for (const entry of seriesEntries) {
// Skip hidden series from crosshair tooltip
if ((entry.alpha ?? 1) < 0.5) continue;
const v = t >= entry.visible[0].time ? interpolateAtTime(entry.visible, t) : null;
if (v !== null) {
hoverEntries.push({ color: entry.palette.line, label: entry.label ?? '', value: v });
}
}
isActiveHover = hoverEntries.length > 0;
if (isActiveHover) {
lastHoverRef.current = { x: clampedX, value: hoverEntries[0].value, time: t };
lastHoverEntriesRef.current = hoverEntries;
cfg.onHover?.({ time: t, value: hoverEntries[0].value, x: clampedX, y: layout.toY(hoverEntries[0].value) });
}
}
if (!isActiveHover) reportHover(null);
// Scrub amount
const scrubTarget = isActiveHover ? 1 : 0;
if (noMotion) {
scrubAmountRef.current = scrubTarget;
} else {
scrubAmountRef.current += (scrubTarget - scrubAmountRef.current) * SCRUB_LERP_SPEED;
if (scrubAmountRef.current < 0.01) scrubAmountRef.current = 0;
if (scrubAmountRef.current > 0.99) scrubAmountRef.current = 1;
}
// Fade-out: use last known hover position + cached entries
if (!isActiveHover && scrubAmountRef.current > 0 && lastHoverRef.current) {
drawHoverX = lastHoverRef.current.x;
drawHoverTime = lastHoverRef.current.time;
hoverEntries = lastHoverEntriesRef.current;
}
// Draw multi-series frame
drawMultiFrame(ctx, layout, {
series: seriesEntries,
now,
showGrid: cfg.showGrid,
showPulse: cfg.showPulse,
referenceLine: cfg.referenceLine,
hoverX: drawHoverX,
hoverTime: drawHoverTime,
hoverEntries,
scrubAmount: scrubAmountRef.current,
windowSecs,
formatValue: cfg.formatValue,
formatTime: cfg.formatTime,
gridState: gridStateRef.current,
timeAxisState: timeAxisStateRef.current,
dt,
targetWindowSecs: cfg.windowSecs,
tooltipY: cfg.tooltipY,
tooltipOutline: cfg.tooltipOutline,
chartReveal,
pauseProgress,
now_ms: animationNow,
primaryPalette: cfg.palette,
reducedMotion: noMotion,
});
// During reverse morph (chart → loading/empty), overlay the empty text
// as chartReveal drops — identical to single-series behavior
const bgAlpha = 1 - chartReveal;
if (bgAlpha > 0.01 && revealTarget === 0 && !cfg.loading) {
const bgEmptyAlpha = (1 - loadingAlpha) * bgAlpha;
if (bgEmptyAlpha > 0.01) {
drawEmpty(ctx, w, h, pad, cfg.palette, bgEmptyAlpha, animationNow, true, cfg.emptyText);
}
}
// Hide badge in multi-series mode
if (badgeRef.current) badgeRef.current.container.style.display = 'none';
const firstEntry = firstVisibleSeries;
if (firstEntry) {
updateValueElement(cfg, smoothValues.get(firstEntry.id) ?? firstEntry.value, 'flat');
}
} else {
// ═══════════════════════════════════════════════════════
// LINE MODE PIPELINE (existing)
// ═══════════════════════════════════════════════════════
const effectivePoints = useStash ? lastDataRef.current : points;
// Adaptive speed + smooth value (freeze lerp when using stashed data)
const adaptiveSpeed = computeAdaptiveSpeed(cfg.value, displayValueRef.current, displayMinRef.current, displayMaxRef.current, cfg.lerpSpeed, noMotion);
if (!useStash) {
displayValueRef.current = lerp(displayValueRef.current, cfg.value, adaptiveSpeed, pausedDt);
// Skip snap when pausing — cfg.value keeps changing from the consumer,
// so the snap would cause visible jumps in a supposedly frozen chart
if (pauseProgress < 0.5) {
const prevRange = displayMaxRef.current - displayMinRef.current || 1;
if (Math.abs(displayValueRef.current - cfg.value) < prevRange * VALUE_SNAP_THRESHOLD) {
displayValueRef.current = cfg.value;
}
}
}
const smoothValue = displayValueRef.current;
const chartW = w - pad.left - pad.right;
// 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(baseBuffer, 37 / Math.max(chartW, 1)) : baseBuffer;
// Window transition
const transition = windowTransitionRef.current;
if (hasData) frozenNowRef.current = epochNow() / 1000 - timeDebtRef.current;
const now = useStash ? frozenNowRef.current : epochNow() / 1000 - timeDebtRef.current;
const windowResult = updateWindowTransition(
cfg,
transition,
displayWindowRef.current,
displayMinRef.current,
displayMaxRef.current,
noMotion,
now_ms,
now,
effectivePoints,
smoothValue,
buffer,
);
displayWindowRef.current = windowResult.windowSecs;
const windowSecs = windowResult.windowSecs;
const windowTransProgress = windowResult.windowTransProgress;
const rightEdge = now + windowSecs * buffer;
const leftEdge = rightEdge - windowSecs;
// Filter visible points — when pausing, contract right edge to `now`
// so new data (with real-time timestamps) can't appear past the live dot
const filterRight = rightEdge - (rightEdge - now) * pauseProgress;
const visible: LivelinePoint[] = [];
for (const p of effectivePoints) {
if (p.time >= leftEdge - 2 && p.time <= filterRight) {
visible.push(p);
}
}
if (visible.length < 2) {
reportHover(null);
if (loadingAlpha > 0.01) drawLoading(ctx, w, h, pad, cfg.palette, animationNow, loadingAlpha);
if (1 - loadingAlpha > 0.01) drawEmpty(ctx, w, h, pad, cfg.palette, 1 - loadingAlpha, animationNow, false, cfg.emptyText);
if (badgeRef.current) badgeRef.current.container.style.display = 'none';
schedule();
return;
}
// Compute + smooth Y range
const computedRange = computeRange(visible, smoothValue, cfg.referenceLine?.value, cfg.exaggerate);
const isWindowTransitioning = transition.startMs > 0;
const rangeResult = updateRange(
computedRange,
rangeInitedRef.current,
targetMinRef.current,
targetMaxRef.current,
displayMinRef.current,
displayMaxRef.current,
isWindowTransitioning,
windowTransProgress,
transition,
adaptiveSpeed,
chartH,
pausedDt,
);
rangeInitedRef.current = rangeResult.rangeInited;
targetMinRef.current = rangeResult.targetMin;
targetMaxRef.current = rangeResult.targetMax;
displayMinRef.current = rangeResult.displayMin;
displayMaxRef.current = rangeResult.displayMax;
const { minVal, maxVal, valRange } = rangeResult;
const layout: ChartLayout = {
w,
h,
pad,
chartW,
chartH,
leftEdge,
rightEdge,
minVal,
maxVal,
valRange,
toX: (t: number) => pad.left + ((t - leftEdge) / (rightEdge - leftEdge)) * chartW,
toY: (v: number) => pad.top + (1 - (v - minVal) / valRange) * chartH,
};
// Momentum
const momentum: Momentum = cfg.momentumOverride ?? detectMomentum(visible);
// Hover + scrub
const hoverResult = updateHoverState(
hoverPixelX,
pad,
w,
layout,
now,
visible,
scrubAmountRef.current,
lastHoverRef.current,
cfg,
noMotion,
leftEdge,
rightEdge,
chartW,
);
scrubAmountRef.current = hoverResult.scrubAmount;
lastHoverRef.current = hoverResult.lastHover;
const { hoverX: drawHoverX, hoverValue: drawHoverValue, hoverTime: drawHoverTime } = hoverResult;
// Compute swing magnitude for particles (recent velocity / visible range)
const lookback = Math.min(5, visible.length - 1);
const recentDelta = lookback > 0 ? Math.abs(visible[visible.length - 1].value - visible[visible.length - 1 - lookback].value) : 0;
const swingMagnitude = valRange > 0 ? Math.min(recentDelta / valRange, 1) : 0;
// Draw canvas content (everything except badge)
drawFrame(ctx, layout, cfg.palette, {
visible,
smoothValue,
now,
momentum,
arrowState: arrowStateRef.current,
showGrid: cfg.showGrid,
showMomentum: cfg.showMomentum,
showPulse: cfg.showPulse,
showFill: cfg.showFill,
referenceLine: cfg.referenceLine,
hoverX: drawHoverX,
hoverValue: drawHoverValue,
hoverTime: drawHoverTime,
scrubAmount: scrubAmountRef.current,
windowSecs,
formatValue: cfg.formatValue,
formatTime: cfg.formatTime,
gridState: gridStateRef.current,
timeAxisState: timeAxisStateRef.current,
dt,
targetWindowSecs: cfg.windowSecs,
tooltipY: cfg.tooltipY,
tooltipOutline: cfg.tooltipOutline,
orderbookData: cfg.orderbookData,
orderbookState: cfg.orderbookData ? orderbookStateRef.current : undefined,
particleState: cfg.degenOptions ? particleStateRef.current : undefined,
particleOptions: cfg.degenOptions,
swingMagnitude,
shakeState: cfg.degenOptions ? shakeStateRef.current : undefined,
chartReveal,
pauseProgress,
now_ms: animationNow,
reducedMotion: noMotion,
});
// During morph (chart ↔ empty), overlay the gradient gap + text on
// top of the morphing chart line. skipLine=true avoids double-drawing
// the squiggly. The gap fades in smoothly as chartReveal drops.
const bgAlpha = 1 - chartReveal;
if (bgAlpha > 0.01 && revealTarget === 0 && !cfg.loading) {
const bgEmptyAlpha = (1 - loadingAlpha) * bgAlpha;
if (bgEmptyAlpha > 0.01) {
drawEmpty(ctx, w, h, pad, cfg.palette, bgEmptyAlpha, animationNow, true, cfg.emptyText);
}
}
// Badge (DOM element, floats above container)
const badge = badgeRef.current;
if (badge) {
badgeYRef.current = updateBadgeDOM(
badge,
cfg,
smoothValue,
layout,
momentum,
badgeYRef.current,
badgeColorRef.current,
isWindowTransitioning,
noMotion,
ctx,
pausedDt,
chartReveal,
);
// Hide badge during pause — fully fades out as pauseProgress → 1
if (pauseProgress > 0.01 && badge.container.style.display !== 'none') {
const base = badge.container.style.opacity ? parseFloat(badge.container.style.opacity) : 1;
badge.container.style.opacity = String(base * (1 - pauseProgress));
}
}
// --- Live value display (DOM element, updated by ref — no React re-renders) ---
updateValueElement(cfg, smoothValue, momentum);
} // end else (line mode)
schedule();
}
if (requestFrame) schedule();
else draw();
return {
update(nextConfig) {
if (destroyed) return;
const scrubDisabled = configRef.current.scrub && !nextConfig.scrub;
configRef.current = sanitizeConfig({
...nextConfig,
valueElement: nextConfig.valueElement === undefined ? elements.value : nextConfig.valueElement,
});
if (scrubDisabled) {
hoverActive = false;
hoverXRef.current = null;
scrubAmountRef.current = 0;
lastHoverRef.current = null;
lastHoverEntriesRef.current = [];
configRef.current.onHover?.(null);
}
// Without rAF, updates still render synchronously when possible.
if (!requestFrame) draw();
else schedule();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (rafRef.current && cancelFrame) cancelFrame(rafRef.current);
rafRef.current = 0;
for (const cleanup of cleanups.splice(0)) cleanup();
configRef.current.onHover?.(null);
ctxRef.current = null;
},
};
}
packages/core/src/liveline/canvas/dpr.ts
/** Get device pixel ratio, clamped to reasonable range. */
export function getDpr(devicePixelRatio?: number): number {
return Math.min(devicePixelRatio || 1, 3);
}
/** Apply DPR scaling to canvas context. */
export function applyDpr(ctx: CanvasRenderingContext2D, dpr: number, w: number, h: number) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
}
packages/core/src/liveline/draw/badge.ts
/**
* Generate SVG path data for the badge pill + curved tail shape.
*
* Coordinate system: (0,0) is top-left of the shape bounding box.
* Total size: (tailLen + pillW) × pillH.
* The tail tip points left at (0, pillH/2).
*/
export function badgeSvgPath(pillW: number, pillH: number, tailLen: number, tailSpread: number): string {
const r = pillH / 2;
const cx = tailLen + pillW - r; // right semicircle center X
const tl = tailLen + r; // top-left junction X
return [
`M${tl},0`,
`L${cx},0`,
`A${r},${r},0,0,1,${cx},${pillH}`,
`L${tl},${pillH}`,
`C${tailLen + 2},${pillH},${3},${r + tailSpread},0,${r}`,
`C${3},${r - tailSpread},${tailLen + 2},0,${tl},0`,
'Z',
].join(' ');
}
/**
* Simple pill (no tail) — a rounded rect.
*/
export function badgePillOnly(pillW: number, pillH: number): string {
const r = pillH / 2;
return [`M${r},0`, `L${pillW - r},0`, `A${r},${r},0,0,1,${pillW - r},${pillH}`, `L${r},${pillH}`, `A${r},${r},0,0,1,${r},0`, 'Z'].join(' ');
}
/** Badge geometry constants */
export const BADGE_PAD_X = 10;
export const BADGE_PAD_Y = 3;
export const BADGE_TAIL_LEN = 5;
export const BADGE_TAIL_SPREAD = 2.5;
export const BADGE_LINE_H = 16;
packages/core/src/liveline/draw/candlestick.ts
import type { ChartLayout, LivelinePalette, CandlePoint } from '../types';
export type { CandlePoint } from '../types';
const BULL = '#22c55e';
const BEAR = '#ef4444';
// Pre-parsed RGB for fast interpolation
const BULL_RGB = [34, 197, 94] as const;
const BEAR_RGB = [239, 68, 68] as const;
/** Blend bear→bull by t (0=bear, 1=bull). */
function blendColor(t: number): string {
const r = Math.round(BEAR_RGB[0] + (BULL_RGB[0] - BEAR_RGB[0]) * t);
const g = Math.round(BEAR_RGB[1] + (BULL_RGB[1] - BEAR_RGB[1]) * t);
const b = Math.round(BEAR_RGB[2] + (BULL_RGB[2] - BEAR_RGB[2]) * t);
return `rgb(${r},${g},${b})`;
}
/** Parse "#rrggbb" or "rgb(r,g,b)" to [r,g,b]. */
function parseRgb(color: string): [number, number, number] {
const hex = color.match(/^#([0-9a-f]{6})$/i);
if (hex) {
const h = hex[1];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
const rgb = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) return [+rgb[1], +rgb[2], +rgb[3]];
return [128, 128, 128];
}
/** Blend a candle color toward an accent color by t. */
function blendToAccent(candleColor: string, accentColor: string, t: number): string {
if (t <= 0) return candleColor;
if (t >= 1) return accentColor;
const [r1, g1, b1] = parseRgb(candleColor);
const [r2, g2, b2] = parseRgb(accentColor);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `rgb(${r},${g},${b})`;
}
/**
* Compute pixel dimensions for candle rendering.
*/
function candleDims(layout: ChartLayout, candleWidthSecs: number) {
const pxPerSec = layout.chartW / (layout.rightEdge - layout.leftEdge);
const candlePxW = candleWidthSecs * pxPerSec;
const bodyW = Math.max(1, candlePxW * 0.7);
const wickW = Math.max(0.8, Math.min(2, bodyW * 0.15));
const radius = bodyW > 6 ? 1.5 : 0;
return { bodyW, wickW, radius };
}
/**
* Rounded rect helper — draws path only (caller fills/strokes).
*/
function roundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
if (r <= 0 || h < r * 2) {
ctx.rect(x, y, w, h);
return;
}
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
/**
* Draw OHLC candlesticks with live candle glow + scrub dimming.
* Respects incoming ctx.globalAlpha for cross-fade/reveal support.
*/
export function drawCandlesticks(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
candles: CandlePoint[],
candleWidthSecs: number,
liveTime: number,
now_ms: number,
scrubX: number,
scrubDim: number,
liveAlpha = 1,
liveBullBlend = -1,
accentColor?: string,
accentBlend = 0,
showPulse = true,
) {
if (candles.length === 0) return;
const { toX, toY } = layout;
const { bodyW, wickW, radius } = candleDims(layout, candleWidthSecs);
const halfBody = bodyW / 2;
const padL = layout.pad.left;
const padR = layout.pad.left + layout.chartW;
// Live pulse: subtle brightness cycle
const livePulse = 0.12 + (showPulse ? Math.sin(now_ms * 0.004) * 0.08 : 0);
for (const c of candles) {
const cx = toX(c.time + candleWidthSecs / 2);
if (cx + halfBody < padL || cx - halfBody > padR) continue;
const isBull = c.close >= c.open;
const isLive = c.time === liveTime;
let color = isLive && liveBullBlend >= 0 ? blendColor(liveBullBlend) : isBull ? BULL : BEAR;
if (accentColor && accentBlend > 0.01) {
color = blendToAccent(color, accentColor, accentBlend);
}
// Scrub dimming: smooth spatial gradient from cursor position
let candleAlpha = isLive ? liveAlpha : 1;
if (scrubDim > 0.01 && scrubX > 0) {
const dist = cx - scrubX;
if (dist > 0) {
const fadeZone = bodyW * 1.5;
const dimT = Math.min(dist / fadeZone, 1);
candleAlpha *= 1 - scrubDim * 0.5 * dimT;
}
}
const baseAlpha = ctx.globalAlpha;
ctx.globalAlpha = baseAlpha * candleAlpha;
// Body geometry
const bodyTop = toY(Math.max(c.open, c.close));
const bodyBottom = toY(Math.min(c.open, c.close));
const bodyH = Math.max(1, bodyBottom - bodyTop);
// Wicks
const wickTop = toY(c.high);
const wickBottom = toY(c.low);
ctx.lineCap = 'round';
ctx.strokeStyle = color;
if (bodyTop - wickTop > 0.5) {
ctx.beginPath();
ctx.moveTo(cx, bodyTop);
ctx.lineTo(cx, wickTop);
ctx.lineWidth = wickW;
ctx.stroke();
}
if (wickBottom - bodyBottom > 0.5) {
ctx.beginPath();
ctx.moveTo(cx, bodyBottom);
ctx.lineTo(cx, wickBottom);
ctx.lineWidth = wickW;
ctx.stroke();
}
// Body
ctx.fillStyle = color;
ctx.beginPath();
roundedRect(ctx, cx - halfBody, bodyTop, bodyW, bodyH, radius);
ctx.fill();
// Live candle glow
if (isLive) {
ctx.save();
ctx.globalAlpha = baseAlpha * candleAlpha * livePulse;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillStyle = color;
ctx.beginPath();
roundedRect(ctx, cx - halfBody, bodyTop, bodyW, bodyH, radius);
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = baseAlpha;
}
}
/**
* Draw a dashed horizontal line at the live close price.
* Dims when scrubbing, uses candle direction color.
*/
export function drawClosePrice(ctx: CanvasRenderingContext2D, layout: ChartLayout, palette: LivelinePalette, liveCandle: CandlePoint, scrubDim: number, bullBlend = -1) {
const y = layout.toY(liveCandle.close);
if (y < layout.pad.top || y > layout.h - layout.pad.bottom) return;
const isBull = liveCandle.close >= liveCandle.open;
const color = bullBlend >= 0 ? blendColor(bullBlend) : isBull ? BULL : BEAR;
const baseAlpha = ctx.globalAlpha;
ctx.save();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.globalAlpha = baseAlpha * (1 - scrubDim * 0.3) * 0.4;
ctx.beginPath();
ctx.moveTo(layout.pad.left, y);
ctx.lineTo(layout.w - layout.pad.right, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
/**
* Draw candlestick crosshair: vertical line + OHLC tooltip.
* All elements respect `opacity` for smooth fade in/out.
*/
export function drawCandleCrosshair(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
hoverX: number,
candle: CandlePoint,
hoverTime: number,
formatValue: (v: number) => string,
formatTime: (t: number) => string,
opacity: number,
tooltipY = 14,
tooltipOutline = true,
) {
if (opacity < 0.01) return;
const { h, pad } = layout;
// Vertical line
ctx.save();
ctx.globalAlpha = opacity * 0.5;
ctx.strokeStyle = palette.crosshairLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(hoverX, pad.top);
ctx.lineTo(hoverX, h - pad.bottom);
ctx.stroke();
ctx.restore();
// Tooltip — OHLC + time (matches line chart crosshair patterns)
if (opacity < 0.1 || layout.w < 200) return;
const isBull = candle.close >= candle.open;
const valueColor = isBull ? BULL : BEAR;
const cl = formatValue(candle.close);
const time = formatTime(hoverTime);
ctx.save();
ctx.globalAlpha = opacity;
ctx.font = '400 13px "SF Mono", Menlo, monospace';
ctx.textAlign = 'left';
// Full OHLC at ≥400px, condensed (close + time) at smaller sizes
let parts: { text: string; color: string }[];
if (layout.w >= 400) {
const o = formatValue(candle.open);
const hi = formatValue(candle.high);
const lo = formatValue(candle.low);
parts = [
{ text: 'O ', color: palette.gridLabel },
{ text: o, color: valueColor },
{ text: ' H ', color: palette.gridLabel },
{ text: hi, color: valueColor },
{ text: ' L ', color: palette.gridLabel },
{ text: lo, color: valueColor },
{ text: ' C ', color: palette.gridLabel },
{ text: cl, color: valueColor },
{ text: ' \u00b7 ', color: palette.gridLabel },
{ text: time, color: palette.gridLabel },
];
} else {
parts = [
{ text: 'C ', color: palette.gridLabel },
{ text: cl, color: valueColor },
{ text: ' \u00b7 ', color: palette.gridLabel },
{ text: time, color: palette.gridLabel },
];
}
// Measure
let totalW = 0;
const widths: number[] = [];
for (const p of parts) {
const w = ctx.measureText(p.text).width;
widths.push(w);
totalW += w;
}
// Position — center on hover, clamp to chart bounds
let tx = hoverX - totalW / 2;
const minX = pad.left + 4;
const maxX = layout.w - pad.right - totalW;
if (tx < minX) tx = minX;
if (tx > maxX) tx = maxX;
const ty = pad.top + tooltipY + 10;
// Outline stroke for readability
let cx = tx;
if (tooltipOutline) {
ctx.strokeStyle = palette.tooltipBg;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
for (let i = 0; i < parts.length; i++) {
ctx.strokeText(parts[i].text, cx, ty);
cx += widths[i];
}
}
// Fill text
cx = tx;
for (let i = 0; i < parts.length; i++) {
ctx.fillStyle = parts[i].color;
ctx.fillText(parts[i].text, cx, ty);
cx += widths[i];
}
ctx.restore();
}
/**
* Simplified crosshair for line mode — single value + time (no OHLC).
*/
export function drawLineModeCrosshair(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
hoverX: number,
value: number,
hoverTime: number,
formatValue: (v: number) => string,
formatTime: (t: number) => string,
opacity: number,
tooltipY = 14,
tooltipOutline = true,
) {
if (opacity < 0.01) return;
const { h, pad } = layout;
const y = layout.toY(value);
ctx.save();
ctx.globalAlpha = opacity * 0.5;
ctx.strokeStyle = palette.crosshairLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(hoverX, pad.top);
ctx.lineTo(hoverX, h - pad.bottom);
ctx.stroke();
ctx.globalAlpha = opacity * 0.3;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(layout.w - pad.right, y);
ctx.stroke();
ctx.restore();
if (opacity < 0.1 || layout.w < 200) return;
const val = formatValue(value);
const time = formatTime(hoverTime);
ctx.save();
ctx.globalAlpha = opacity;
ctx.font = '400 13px "SF Mono", Menlo, monospace';
ctx.textAlign = 'left';
const parts: { text: string; color: string }[] = [
{ text: val, color: palette.line },
{ text: ' \u00b7 ', color: palette.gridLabel },
{ text: time, color: palette.gridLabel },
];
let totalW = 0;
const widths: number[] = [];
for (const p of parts) {
const w = ctx.measureText(p.text).width;
widths.push(w);
totalW += w;
}
let tx = hoverX - totalW / 2;
const minX = pad.left + 4;
const maxX = layout.w - pad.right - totalW;
if (tx < minX) tx = minX;
if (tx > maxX) tx = maxX;
const ty = pad.top + tooltipY + 10;
let lx = tx;
if (tooltipOutline) {
ctx.strokeStyle = palette.tooltipBg;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
for (let i = 0; i < parts.length; i++) {
ctx.strokeText(parts[i].text, lx, ty);
lx += widths[i];
}
}
lx = tx;
for (let i = 0; i < parts.length; i++) {
ctx.fillStyle = parts[i].color;
ctx.fillText(parts[i].text, lx, ty);
lx += widths[i];
}
ctx.restore();
}
packages/core/src/liveline/draw/crosshair.ts
import type { LivelinePalette, ChartLayout } from '../types';
export interface MultiSeriesHoverEntry {
color: string;
label: string;
value: number;
}
export function drawCrosshair(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
hoverX: number,
hoverValue: number,
hoverTime: number,
formatValue: (v: number) => string,
formatTime: (t: number) => string,
scrubOpacity: number,
tooltipY?: number,
liveDotX?: number,
tooltipOutline?: boolean,
) {
if (scrubOpacity < 0.01) return;
const { h, pad, toY } = layout;
const y = toY(hoverValue);
// Vertical line (solid, like Kalshi)
ctx.save();
ctx.globalAlpha = scrubOpacity * 0.5;
ctx.strokeStyle = palette.crosshairLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(hoverX, pad.top);
ctx.lineTo(hoverX, h - pad.bottom);
ctx.stroke();
ctx.restore();
// Dot at intersection — solid accent color, always fully opaque.
// Radius scales with scrubOpacity for smooth appear/disappear.
const dotRadius = 4 * Math.min(scrubOpacity * 3, 1);
if (dotRadius > 0.5) {
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(hoverX, y, dotRadius, 0, Math.PI * 2);
ctx.fillStyle = palette.line;
ctx.fill();
}
// Top label: "$VALUE - TIME" — fixed at top, moves horizontally only
// Skip text for small containers (text is ~200px wide)
if (scrubOpacity < 0.1 || layout.w < 300) return;
const valueText = formatValue(hoverValue);
const timeText = formatTime(hoverTime);
const separator = ' · ';
ctx.save();
ctx.globalAlpha = scrubOpacity;
ctx.font = '400 13px "SF Mono", Menlo, monospace';
const valueW = ctx.measureText(valueText).width;
const sepW = ctx.measureText(separator).width;
const timeW = ctx.measureText(timeText).width;
const totalW = valueW + sepW + timeW;
// Center on crosshair, clamp to chart bounds
// Right edge of tooltip text aligns with the right edge of the live dot circle
let tx = hoverX - totalW / 2;
const minX = pad.left + 4;
const dotRightEdge = liveDotX != null ? liveDotX + 7 : layout.w - pad.right;
const maxX = dotRightEdge - totalW;
if (tx < minX) tx = minX;
if (tx > maxX) tx = maxX;
const ty = pad.top + (tooltipY ?? 14) + 10; // offset from top
ctx.textAlign = 'left';
// Text outline for readability against the chart
if (tooltipOutline) {
ctx.strokeStyle = palette.tooltipBg;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeText(valueText, tx, ty);
ctx.strokeText(separator + timeText, tx + valueW, ty);
}
// Value (dark)
ctx.fillStyle = palette.tooltipText;
ctx.fillText(valueText, tx, ty);
// Separator + time (lighter)
ctx.fillStyle = palette.gridLabel;
ctx.fillText(separator + timeText, tx + valueW, ty);
ctx.restore();
}
/** Multi-series crosshair: vertical line + inline text at top, matching single-series style. */
export function drawMultiCrosshair(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
hoverX: number,
hoverTime: number,
entries: MultiSeriesHoverEntry[],
formatValue: (v: number) => string,
formatTime: (t: number) => string,
scrubOpacity: number,
tooltipY?: number,
tooltipOutline?: boolean,
liveDotX?: number,
) {
if (scrubOpacity < 0.01 || entries.length === 0) return;
const { h, pad, toY } = layout;
// Vertical line (solid, matching single-series)
ctx.save();
ctx.globalAlpha = scrubOpacity * 0.5;
ctx.strokeStyle = palette.crosshairLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(hoverX, pad.top);
ctx.lineTo(hoverX, h - pad.bottom);
ctx.stroke();
ctx.restore();
// Dots at each series intersection — radius scales with scrubOpacity,
// alpha stays at 1 (matching single-series crosshair dot behavior)
const dotRadius = 4 * Math.min(scrubOpacity * 3, 1);
if (dotRadius > 0.5) {
ctx.globalAlpha = 1;
for (const entry of entries) {
const y = toY(entry.value);
ctx.beginPath();
ctx.arc(hoverX, y, dotRadius, 0, Math.PI * 2);
ctx.fillStyle = entry.color;
ctx.fill();
}
}
if (scrubOpacity < 0.1 || layout.w < 300) return;
// Inline text at top — same style as single-series crosshair
// Format: "TIME · ● Label Value · ● Label Value"
ctx.save();
ctx.globalAlpha = scrubOpacity;
ctx.font = '400 13px "SF Mono", Menlo, monospace';
ctx.textAlign = 'left';
const timeText = formatTime(hoverTime);
const sep = ' · ';
const dotInline = ' '; // spacing for inline colored dot
// Build segments: [ { text, color } ... ]
type Seg = { text: string; color: string; isDot?: boolean };
const segments: Seg[] = [{ text: timeText, color: palette.gridLabel }];
for (const e of entries) {
segments.push({ text: sep, color: palette.gridLabel });
// Inline dot (drawn as circle, not text)
segments.push({ text: dotInline, color: e.color, isDot: true });
const label = e.label ? `${e.label} ` : '';
if (label) segments.push({ text: label, color: palette.gridLabel });
segments.push({ text: formatValue(e.value), color: palette.tooltipText });
}
// Measure total width
let totalW = 0;
const segWidths: number[] = [];
for (const seg of segments) {
const w = seg.isDot ? 12 : ctx.measureText(seg.text).width;
segWidths.push(w);
totalW += w;
}
// Position — center on crosshair, clamp to chart bounds
// Right edge of tooltip aligns with the right edge of live dots (matching single-series)
let tx = hoverX - totalW / 2;
const minX = pad.left + 4;
const dotRightEdge = liveDotX != null ? liveDotX + 7 : layout.w - pad.right;
const maxX = dotRightEdge - totalW;
if (tx < minX) tx = minX;
if (tx > maxX) tx = maxX;
const ty = pad.top + (tooltipY ?? 14) + 10;
// Outline pass
if (tooltipOutline !== false) {
ctx.strokeStyle = palette.tooltipBg;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
let ox = tx;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (!seg.isDot) {
ctx.strokeText(seg.text, ox, ty);
}
ox += segWidths[i];
}
}
// Fill pass
let ox = tx;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg.isDot) {
// Draw small colored circle inline
ctx.beginPath();
ctx.arc(ox + 4, ty - 4, 3.5, 0, Math.PI * 2);
ctx.fillStyle = seg.color;
ctx.fill();
} else {
ctx.fillStyle = seg.color;
ctx.fillText(seg.text, ox, ty);
}
ox += segWidths[i];
}
ctx.restore();
}
packages/core/src/liveline/draw/dot.ts
import type { Momentum, LivelinePalette } from '../types';
import type { ArrowState } from './index';
import { parseColorRgb } from '../theme';
import { lerp } from '../math/lerp';
const PULSE_INTERVAL = 1500;
const PULSE_DURATION = 900;
function lerpColor(a: [number, number, number], b: [number, number, number], t: number): string {
const r = Math.round(a[0] + (b[0] - a[0]) * t);
const g = Math.round(a[1] + (b[1] - a[1]) * t);
const bl = Math.round(a[2] + (b[2] - a[2]) * t);
return `rgb(${r},${g},${bl})`;
}
/** Draw the live dot: expanding ring pulse, white outer circle, colored inner dot. */
export function drawDot(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
palette: LivelinePalette,
pulse: boolean = true,
scrubAmount: number = 0,
now_ms: number = performance.now(),
): void {
const baseAlpha = ctx.globalAlpha;
const dim = scrubAmount * 0.7;
// Expanding ring pulse (accent colored, every 1.5s) — suppress when dimmed
if (pulse && dim < 0.3) {
const t = (now_ms % PULSE_INTERVAL) / PULSE_DURATION;
if (t < 1) {
const radius = 9 + t * 12;
const pulseAlpha = 0.35 * (1 - t) * (1 - dim * 3);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.strokeStyle = palette.line;
ctx.lineWidth = 1.5;
ctx.globalAlpha = baseAlpha * pulseAlpha;
ctx.stroke();
}
}
// Outer bg color for blending
const outerRgb = parseColorRgb(palette.badgeOuterBg);
// White outer circle with subtle shadow
ctx.save();
ctx.globalAlpha = baseAlpha;
ctx.shadowColor = palette.badgeOuterShadow;
ctx.shadowBlur = 6 * (1 - dim);
ctx.shadowOffsetY = 1;
ctx.beginPath();
ctx.arc(x, y, 6.5, 0, Math.PI * 2);
ctx.fillStyle = palette.badgeOuterBg;
ctx.fill();
ctx.restore();
// Colored inner dot — blend toward outer bg when dimmed
ctx.globalAlpha = baseAlpha;
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, Math.PI * 2);
if (dim > 0.01) {
const lineRgb = parseColorRgb(palette.line);
ctx.fillStyle = lerpColor(lineRgb, outerRgb, dim);
} else {
ctx.fillStyle = palette.line;
}
ctx.fill();
}
/** Draw a multi-series endpoint dot with optional pulse ring (colored ring + solid dot, no white outer, no shadow). */
export function drawMultiDot(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
pulse: boolean = true,
now_ms: number = performance.now(),
radius: number = 3,
): void {
const baseAlpha = ctx.globalAlpha;
// Expanding ring pulse (series-colored, every 1.5s)
if (pulse) {
const t = (now_ms % PULSE_INTERVAL) / PULSE_DURATION;
if (t < 1) {
const ringRadius = 9 + t * 10;
const pulseAlpha = 0.3 * (1 - t);
ctx.beginPath();
ctx.arc(x, y, ringRadius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = baseAlpha * pulseAlpha;
ctx.stroke();
}
}
// Solid colored dot (no white outer, no shadow)
ctx.globalAlpha = baseAlpha;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
/** Draw a small colored dot for multi-series endpoints (no ring, no pulse, no shadow). */
export function drawSimpleDot(ctx: CanvasRenderingContext2D, x: number, y: number, color: string, radius: number = 3): void {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
/** Draw momentum arrows (chevrons) next to the dot. */
export function drawArrows(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
momentum: Momentum,
palette: LivelinePalette,
arrows: ArrowState,
dt: number,
now_ms: number = performance.now(),
reducedMotion: boolean = false,
): void {
const baseAlpha = ctx.globalAlpha;
// Update arrow opacities — fade out old direction fully before fading in new
const upTarget = momentum === 'up' ? 1 : 0;
const downTarget = momentum === 'down' ? 1 : 0;
const canFadeInUp = arrows.down < 0.02;
const canFadeInDown = arrows.up < 0.02;
arrows.up = reducedMotion ? upTarget : lerp(arrows.up, canFadeInUp ? upTarget : 0, upTarget > arrows.up ? 0.08 : 0.04, dt);
arrows.down = reducedMotion ? downTarget : lerp(arrows.down, canFadeInDown ? downTarget : 0, downTarget > arrows.down ? 0.08 : 0.04, dt);
if (arrows.up < 0.01) arrows.up = 0;
if (arrows.down < 0.01) arrows.down = 0;
if (arrows.up > 0.99) arrows.up = 1;
if (arrows.down > 0.99) arrows.down = 1;
// Draw chevrons — directional cascade animation.
// UP: bottom arrow fires first, then top (energy moves upward).
// DOWN: top arrow fires first, then bottom.
const cycle = (now_ms % 1400) / 1400;
const drawChevrons = (dir: -1 | 1, opacity: number) => {
if (opacity < 0.01) return;
const baseX = x + 19;
const baseY = y;
ctx.save();
ctx.strokeStyle = palette.gridLabel;
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let i = 0; i < 2; i++) {
// Stagger: arrow 0 brightens at t=0, arrow 1 at t=0.2
// Both always visible (min 0.3), cascade just brightens each in sequence
const start = i * 0.2;
const dur = 0.35;
const localT = cycle - start;
const wave = localT >= 0 && localT < dur ? Math.sin((localT / dur) * Math.PI) : 0;
const pulse = reducedMotion ? 1 : 0.3 + 0.7 * wave;
ctx.globalAlpha = baseAlpha * opacity * pulse;
const nudge = dir === -1 ? -3 : 3;
const cy = baseY + dir * (i * 8 - 4) + nudge;
ctx.beginPath();
ctx.moveTo(baseX - 5, cy - dir * 3.5);
ctx.lineTo(baseX, cy);
ctx.lineTo(baseX + 5, cy - dir * 3.5);
ctx.stroke();
}
ctx.restore();
};
drawChevrons(-1, arrows.up);
drawChevrons(1, arrows.down);
ctx.globalAlpha = baseAlpha;
}
packages/core/src/liveline/draw/empty.ts
import type { LivelinePalette, Padding } from '../types';
import { drawSpline } from '../math/spline';
import { loadingY, loadingBreath, LOADING_AMPLITUDE_RATIO, LOADING_SCROLL_SPEED } from './loadingShape';
/**
* Draw the empty/no-data state: a breathing squiggly line (grey) with
* a gradient gap in the middle where "No data to display" text sits.
*
* skipLine=true skips the squiggly line but still draws the gradient
* gap + text. Used as an overlay during chart morph so the gap fades
* in smoothly over the morphing chart line.
*/
export function drawEmpty(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
pad: Required<Padding>,
palette: LivelinePalette,
alpha: number = 1,
now_ms: number = 0,
skipLine: boolean = false,
emptyText?: string,
): void {
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const centerY = pad.top + chartH / 2;
const cx = pad.left + chartW / 2;
const text = emptyText ?? 'No data to display';
const amplitude = chartH * LOADING_AMPLITUDE_RATIO;
ctx.save();
ctx.font = '400 12px system-ui, -apple-system, sans-serif';
// Measure text to know gap size
const textW = ctx.measureText(text).width;
const gapHalf = textW / 2 + 20; // padding around text
const fadeW = 30; // gradient fade width on each side
if (!skipLine) {
const scroll = now_ms * LOADING_SCROLL_SPEED;
const breath = loadingBreath(now_ms);
// Breathing squiggly line — same shape as drawLoading but grey
const numPts = 32;
const pts: [number, number][] = [];
for (let i = 0; i <= numPts; i++) {
const t = i / numPts;
const x = pad.left + t * chartW;
const y = loadingY(t, centerY, amplitude, scroll);
pts.push([x, y]);
}
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
drawSpline(ctx, pts);
ctx.strokeStyle = palette.gridLabel;
ctx.lineWidth = palette.lineWidth;
ctx.globalAlpha = breath * alpha;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
// Gradient gap — erases whatever line is on the canvas in the text region.
// Always drawn (even with skipLine) so it fades in over the morphing chart line.
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const gapLeft = cx - gapHalf - fadeW;
const gapRight = cx + gapHalf + fadeW;
const eraseGrad = ctx.createLinearGradient(gapLeft, 0, gapRight, 0);
eraseGrad.addColorStop(0, 'rgba(0,0,0,0)');
eraseGrad.addColorStop(fadeW / (gapRight - gapLeft), 'rgba(0,0,0,1)');
eraseGrad.addColorStop(1 - fadeW / (gapRight - gapLeft), 'rgba(0,0,0,1)');
eraseGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = eraseGrad;
ctx.globalAlpha = alpha;
// Erase tall enough to cover the line's full amplitude
const eraseH = amplitude * 2 + palette.lineWidth + 6;
ctx.fillRect(gapLeft, centerY - eraseH / 2, gapRight - gapLeft, eraseH);
ctx.restore();
// "No data to display" text
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.globalAlpha = 0.35 * alpha;
ctx.fillStyle = palette.gridLabel;
ctx.fillText(text, cx, centerY);
ctx.restore();
}
packages/core/src/liveline/draw/grid.ts
import type { LivelinePalette, ChartLayout } from '../types';
import { lerp } from '../math/lerp';
/**
* Pick a nice interval using TradingView's cycling divisor approach.
* Hysteresis: once chosen, sticks until spacing falls outside [0.5×, 4×] of minGap.
*/
function pickInterval(valRange: number, pxPerUnit: number, minGap: number, prev: number): number {
if (prev > 0) {
const px = prev * pxPerUnit;
if (px >= minGap * 0.5 && px <= minGap * 4) return prev;
}
const divisorSets = [
[2, 2.5, 2],
[2, 2, 2.5],
[2.5, 2, 2],
];
let best = Infinity;
for (const divs of divisorSets) {
let span = Math.pow(10, Math.ceil(Math.log10(valRange)));
let i = 0;
while ((span / divs[i % 3]) * pxPerUnit >= minGap) {
span /= divs[i % 3];
i++;
}
if (span < best) best = span;
}
return best === Infinity ? valRange / 5 : best;
}
/** Float-safe divisibility check. */
function divisible(val: number, interval: number): boolean {
const ratio = val / interval;
return Math.abs(ratio - Math.round(ratio)) < 0.01;
}
/** Persistent state — interval hysteresis + per-label alpha smoothing. */
export interface GridState {
interval: number;
labels: Map<number, number>; // key → alpha
}
const FADE_IN = 0.18;
const FADE_OUT = 0.12;
export function drawGrid(ctx: CanvasRenderingContext2D, layout: ChartLayout, palette: LivelinePalette, formatValue: (v: number) => string, state: GridState, dt: number) {
const { w, h, pad, valRange, minVal, maxVal, toY } = layout;
const chartH = h - pad.top - pad.bottom;
if (chartH <= 0 || valRange <= 0) return;
const pxPerUnit = chartH / valRange;
// Coarse interval: always-visible anchor labels
const coarse = pickInterval(valRange, pxPerUnit, 36, state.interval);
state.interval = coarse;
// Fine interval: fills the gaps between coarse labels
const fine = coarse / 2;
const finePx = fine * pxPerUnit;
// Target alpha for fine labels — hide when cramped, fade in with space
const fineTarget = finePx < 40 ? 0 : finePx >= 60 ? 1 : (finePx - 40) / 20;
// Edge fade
const fadeZone = 32;
const edgeAlpha = (y: number): number => {
const fromEdge = Math.min(y - pad.top, h - pad.bottom - y);
if (fromEdge >= fadeZone) return 1;
if (fromEdge <= 0) return 0;
return fromEdge / fadeZone;
};
// --- Phase 1: compute target alpha for every current grid label ---
const targets = new Map<number, number>();
const first = Math.ceil(minVal / fine) * fine;
for (let val = first; val <= maxVal; val += fine) {
const y = toY(val);
if (y < pad.top - 2 || y > h - pad.bottom + 2) continue;
const isCoarse = divisible(val, coarse);
const target = (isCoarse ? 1 : fineTarget) * edgeAlpha(y);
const key = Number(val.toPrecision(15));
targets.set(key, target);
}
// --- Phase 2: update all tracked label alphas ---
for (const [key, alpha] of state.labels) {
const target = targets.get(key) ?? 0;
const speed = target >= alpha ? FADE_IN : FADE_OUT;
let next = lerp(alpha, target, speed, dt);
if (Math.abs(next - target) < 0.02) next = target;
if (next < 0.01 && target === 0) {
state.labels.delete(key);
} else {
state.labels.set(key, next);
}
}
// New labels not yet in state
for (const [key, target] of targets) {
if (!state.labels.has(key)) {
state.labels.set(key, target * FADE_IN);
}
}
// --- Phase 3: draw ---
const baseAlpha = ctx.globalAlpha;
ctx.setLineDash([1, 3]);
ctx.lineWidth = 1;
ctx.font = palette.labelFont;
ctx.textAlign = 'left';
for (const [key, alpha] of state.labels) {
if (alpha < 0.02) continue;
const val = key;
const y = toY(val);
if (y < pad.top - 10 || y > h - pad.bottom + 10) continue;
ctx.save();
ctx.globalAlpha = baseAlpha * alpha;
ctx.strokeStyle = palette.gridLine;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(w - pad.right, y);
ctx.stroke();
ctx.fillStyle = palette.gridLabel;
ctx.fillText(formatValue(val), w - pad.right + 8, y + 4);
ctx.restore();
}
ctx.setLineDash([]);
}
packages/core/src/liveline/draw/index.ts
import type { LivelinePalette, ChartLayout, LivelinePoint, Momentum, ReferenceLine, OrderbookData, DegenOptions, CandlePoint } from '../types';
import { drawGrid, type GridState } from './grid';
import { drawLine } from './line';
import { drawDot, drawArrows, drawSimpleDot, drawMultiDot } from './dot';
import { drawCrosshair, drawMultiCrosshair } from './crosshair';
import type { MultiSeriesHoverEntry } from './crosshair';
import { drawReferenceLine } from './referenceLine';
import { drawTimeAxis, type TimeAxisState } from './timeAxis';
import { drawOrderbook, type OrderbookState } from './orderbook';
import { drawParticles, spawnOnSwing, type ParticleState } from './particles';
import { drawCandlesticks, drawClosePrice, drawCandleCrosshair, drawLineModeCrosshair } from './candlestick';
import { drawEmpty } from './empty';
// Constants
const SHAKE_DECAY_RATE = 0.002;
const SHAKE_MIN_AMPLITUDE = 0.2;
export const FADE_EDGE_WIDTH = 40;
const CROSSHAIR_FADE_MIN_PX = 5;
export interface ArrowState {
up: number;
down: number;
}
export interface ShakeState {
amplitude: number; // current shake magnitude in px, decays each frame
}
export function createShakeState(): ShakeState {
return { amplitude: 0 };
}
export interface DrawOptions {
visible: LivelinePoint[];
smoothValue: number;
now: number; // engine's Date.now()/1000, single timestamp for the frame
momentum: Momentum;
arrowState: ArrowState;
showGrid: boolean;
showMomentum: boolean;
showPulse: boolean;
showFill: boolean;
referenceLine?: ReferenceLine;
hoverX: number | null;
hoverValue: number | null;
hoverTime: number | null;
scrubAmount: number; // 0 = not scrubbing, 1 = fully scrubbing (lerped)
windowSecs: number;
formatValue: (v: number) => string;
formatTime: (t: number) => string;
gridState: GridState;
timeAxisState: TimeAxisState;
dt: number; // delta time in ms for frame-rate-independent lerps
targetWindowSecs: number; // final target window (stable during transitions)
tooltipY: number;
tooltipOutline: boolean;
orderbookData?: OrderbookData;
orderbookState?: OrderbookState;
particleState?: ParticleState;
particleOptions?: DegenOptions;
swingMagnitude: number;
shakeState?: ShakeState;
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
reducedMotion?: boolean;
}
/**
* Master draw function — calls each draw module in order.
* Mutates arrowState in place.
*/
export function drawFrame(ctx: CanvasRenderingContext2D, layout: ChartLayout, palette: LivelinePalette, opts: DrawOptions): void {
// 0. Chart shake — apply offset, decay amplitude
const shake = opts.reducedMotion ? undefined : opts.shakeState;
let shakeX = 0;
let shakeY = 0;
if (shake && shake.amplitude > SHAKE_MIN_AMPLITUDE) {
shakeX = (Math.random() - 0.5) * 2 * shake.amplitude;
shakeY = (Math.random() - 0.5) * 2 * shake.amplitude;
ctx.save();
ctx.translate(shakeX, shakeY);
}
if (shake) {
// Exponential decay — ~200ms of visible shake
const decayRate = Math.pow(SHAKE_DECAY_RATE, opts.dt / 1000);
shake.amplitude *= decayRate;
if (shake.amplitude < SHAKE_MIN_AMPLITUDE) shake.amplitude = 0;
}
const reveal = opts.chartReveal;
const pause = opts.pauseProgress;
// Smoothstep helper for staggered reveal
const revealRamp = (start: number, end: number) => {
const t = Math.max(0, Math.min(1, (reveal - start) / (end - start)));
return t * t * (3 - 2 * t);
};
// 1. Reference line (behind everything) — fades with reveal
if (opts.referenceLine && reveal > 0.01) {
ctx.save();
if (reveal < 1) ctx.globalAlpha = reveal;
drawReferenceLine(ctx, layout, palette, opts.referenceLine);
ctx.restore();
}
// 2. Grid — fades in delayed (15%–70% of reveal)
if (opts.showGrid) {
const gridAlpha = reveal < 1 ? revealRamp(0.15, 0.7) : 1;
if (gridAlpha > 0.01) {
ctx.save();
if (gridAlpha < 1) ctx.globalAlpha = gridAlpha;
drawGrid(ctx, layout, palette, opts.formatValue, opts.gridState, opts.dt);
ctx.restore();
}
}
// 2b. Orderbook (behind line) — fades with reveal
if (opts.orderbookData && opts.orderbookState && reveal > 0.01) {
ctx.save();
if (reveal < 1) ctx.globalAlpha = reveal;
drawOrderbook(ctx, layout, palette, opts.orderbookData, opts.dt, opts.orderbookState, opts.swingMagnitude);
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);
// 4. Time axis — same timing as grid
{
const timeAlpha = reveal < 1 ? revealRamp(0.15, 0.7) : 1;
if (timeAlpha > 0.01) {
ctx.save();
if (timeAlpha < 1) ctx.globalAlpha = timeAlpha;
drawTimeAxis(ctx, layout, palette, opts.windowSecs, opts.targetWindowSecs, opts.formatTime, opts.timeAxisState, opts.dt);
ctx.restore();
}
}
if (pts && pts.length > 0) {
const lastPt = pts[pts.length - 1];
// 5. Dot — dims during scrub, fades in with reveal (0.3 → 1.0)
let dotScrub = opts.scrubAmount;
if (opts.hoverX !== null && dotScrub > 0) {
const distToLive = lastPt[0] - opts.hoverX;
const fadeStart = Math.min(80, layout.chartW * 0.3);
dotScrub =
distToLive < CROSSHAIR_FADE_MIN_PX
? 0
: distToLive >= fadeStart
? opts.scrubAmount
: ((distToLive - CROSSHAIR_FADE_MIN_PX) / (fadeStart - CROSSHAIR_FADE_MIN_PX)) * opts.scrubAmount;
}
// Dot appears once shape is recognizable (reveal > 0.3)
const dotAlpha = reveal < 0.3 ? 0 : (reveal - 0.3) / 0.7;
const showPulse = opts.showPulse && !opts.reducedMotion && reveal > 0.6 && pause < 0.5;
if (dotAlpha > 0.01) {
ctx.save();
if (dotAlpha < 1) ctx.globalAlpha = dotAlpha;
drawDot(ctx, lastPt[0], lastPt[1], palette, showPulse, dotScrub, opts.now_ms);
ctx.restore();
}
// 5b. Arrows — appear late in reveal (60%+), fade with pause
if (opts.showMomentum) {
const arrowReveal = reveal < 1 ? revealRamp(0.6, 1) : 1;
const arrowAlpha = arrowReveal * (1 - pause);
if (arrowAlpha > 0.01) {
ctx.save();
if (arrowAlpha < 1) ctx.globalAlpha = arrowAlpha;
drawArrows(ctx, lastPt[0], lastPt[1], opts.momentum, palette, opts.arrowState, opts.dt, opts.now_ms, opts.reducedMotion);
ctx.restore();
}
}
// 6. Particles — only when fully revealed
if (opts.particleState && !opts.reducedMotion && reveal > 0.9) {
const burstIntensity = spawnOnSwing(opts.particleState, opts.momentum, lastPt[0], lastPt[1], opts.swingMagnitude, palette.line, opts.dt, opts.particleOptions);
if (burstIntensity > 0 && shake) {
shake.amplitude = (3 + opts.swingMagnitude * 4) * burstIntensity;
}
drawParticles(ctx, opts.particleState, opts.dt);
}
}
// 7. Left edge fade — gradient erase
const fadeW = FADE_EDGE_WIDTH;
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const fadeGrad = ctx.createLinearGradient(layout.pad.left, 0, layout.pad.left + fadeW, 0);
fadeGrad.addColorStop(0, 'rgba(0, 0, 0, 1)');
fadeGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = fadeGrad;
ctx.fillRect(0, 0, layout.pad.left + fadeW, layout.h);
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) {
const lastPt = pts[pts.length - 1];
const distToLive = lastPt[0] - opts.hoverX;
const fadeStart = Math.min(80, layout.chartW * 0.3);
const scrubOpacity =
distToLive < CROSSHAIR_FADE_MIN_PX
? 0
: distToLive >= fadeStart
? opts.scrubAmount
: ((distToLive - CROSSHAIR_FADE_MIN_PX) / (fadeStart - CROSSHAIR_FADE_MIN_PX)) * opts.scrubAmount;
if (scrubOpacity > 0.01) {
drawCrosshair(
ctx,
layout,
palette,
opts.hoverX,
opts.hoverValue,
opts.hoverTime,
opts.formatValue,
opts.formatTime,
scrubOpacity,
opts.tooltipY,
lastPt[0], // liveDotX — tooltip right edge stops here
opts.tooltipOutline,
);
}
}
// Restore shake translate
if (shake && (shakeX !== 0 || shakeY !== 0)) {
ctx.restore();
}
}
// ─── Multi-series draw orchestration ──────────────────────────────────────
export interface MultiSeriesEntry {
visible: LivelinePoint[];
smoothValue: number;
palette: LivelinePalette;
label?: string;
alpha?: number; // series visibility alpha (0 = hidden, 1 = visible)
}
export interface MultiSeriesDrawOptions {
series: MultiSeriesEntry[];
now: number;
showGrid: boolean;
showPulse: boolean;
referenceLine?: ReferenceLine;
hoverX: number | null;
hoverTime: number | null;
hoverEntries: MultiSeriesHoverEntry[];
scrubAmount: number;
windowSecs: number;
formatValue: (v: number) => string;
formatTime: (t: number) => string;
gridState: GridState;
timeAxisState: TimeAxisState;
dt: number;
targetWindowSecs: number;
tooltipY: number;
tooltipOutline: boolean;
chartReveal: number;
pauseProgress: number;
now_ms: number;
/** Primary palette (from first series) for grid/axis/crosshair colors */
primaryPalette: LivelinePalette;
reducedMotion?: boolean;
}
/**
* Multi-series draw function — draws multiple overlapping lines sharing the same axes.
* No fill, no momentum arrows, no badge (those are per-chart concerns handled by the engine).
*/
export function drawMultiFrame(ctx: CanvasRenderingContext2D, layout: ChartLayout, opts: MultiSeriesDrawOptions): void {
const palette = opts.primaryPalette;
const reveal = opts.chartReveal;
const revealRamp = (start: number, end: number) => {
const t = Math.max(0, Math.min(1, (reveal - start) / (end - start)));
return t * t * (3 - 2 * t);
};
// 1. Reference line
if (opts.referenceLine && reveal > 0.01) {
ctx.save();
if (reveal < 1) ctx.globalAlpha = reveal;
drawReferenceLine(ctx, layout, palette, opts.referenceLine);
ctx.restore();
}
// 2. Grid
if (opts.showGrid) {
const gridAlpha = reveal < 1 ? revealRamp(0.15, 0.7) : 1;
if (gridAlpha > 0.01) {
ctx.save();
if (gridAlpha < 1) ctx.globalAlpha = gridAlpha;
drawGrid(ctx, layout, palette, opts.formatValue, opts.gridState, opts.dt);
ctx.restore();
}
}
// 3. Draw each series line (back to front, no fill, with scrub dimming)
// During reverse morph, secondary lines fade out so only one remains at
// chartReveal=0 — prevents alpha compounding from multiple overlapping strokes
// looking brighter than the single standalone loading squiggly.
const scrubX = opts.scrubAmount > 0.05 ? opts.hoverX : null;
const allPts: { pts: [number, number][]; palette: LivelinePalette; label?: string; alpha: number }[] = [];
for (let si = 0; si < opts.series.length; si++) {
const s = opts.series[si];
const seriesAlpha = s.alpha ?? 1;
const secondaryFade = si > 0 && reveal < 1 ? Math.min(1, reveal * 2) : 1;
const combinedAlpha = secondaryFade * seriesAlpha;
if (combinedAlpha < 0.01) continue;
ctx.save();
if (combinedAlpha < 1) ctx.globalAlpha = combinedAlpha;
const pts = drawLine(
ctx,
layout,
s.palette,
s.visible,
s.smoothValue,
opts.now,
false, // no fill
scrubX,
opts.scrubAmount,
reveal,
opts.now_ms,
);
ctx.restore();
if (pts && pts.length > 0) {
allPts.push({ pts, palette: s.palette, label: s.label, alpha: seriesAlpha });
}
}
// 4. Time axis
{
const timeAlpha = reveal < 1 ? revealRamp(0.15, 0.7) : 1;
if (timeAlpha > 0.01) {
ctx.save();
if (timeAlpha < 1) ctx.globalAlpha = timeAlpha;
drawTimeAxis(ctx, layout, palette, opts.windowSecs, opts.targetWindowSecs, opts.formatTime, opts.timeAxisState, opts.dt);
ctx.restore();
}
}
// 5. Endpoint dots + labels for each series
// Dots stay at reveal-based alpha only (no scrub dimming) — matching
// single-series where drawDot keeps inner dot at full baseAlpha
if (reveal > 0.3 && allPts.length > 0) {
const dotAlpha = (reveal - 0.3) / 0.7;
const showPulse = opts.showPulse && !opts.reducedMotion && reveal > 0.6 && opts.pauseProgress < 0.5;
for (const entry of allPts) {
if (entry.alpha < 0.01) continue;
const lastPt = entry.pts[entry.pts.length - 1];
ctx.save();
ctx.globalAlpha = dotAlpha * entry.alpha;
// Use pulsing dot when enabled and series is mostly visible
if (showPulse && entry.alpha > 0.5) {
drawMultiDot(ctx, lastPt[0], lastPt[1], entry.palette.line, true, opts.now_ms, 3);
} else {
drawSimpleDot(ctx, lastPt[0], lastPt[1], entry.palette.line, 3);
}
// Label at endpoint (right of dot — layout reserves space via labelReserve)
if (entry.label) {
ctx.font = '600 10px -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = entry.palette.line;
ctx.fillText(entry.label, lastPt[0] + 6, lastPt[1] + 3.5);
}
ctx.restore();
}
}
// 6. Left edge fade
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const fadeGrad = ctx.createLinearGradient(layout.pad.left, 0, layout.pad.left + FADE_EDGE_WIDTH, 0);
fadeGrad.addColorStop(0, 'rgba(0, 0, 0, 1)');
fadeGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = fadeGrad;
ctx.fillRect(0, 0, layout.pad.left + FADE_EDGE_WIDTH, layout.h);
ctx.restore();
// 7. Multi-series crosshair — fade out near live dots (same logic as single-series)
if (opts.hoverX !== null && opts.hoverTime !== null && opts.hoverEntries.length > 0 && allPts.length > 0 && opts.scrubAmount > 0.01) {
// Find rightmost live dot X (skip hidden series)
let maxLiveDotX = 0;
for (const entry of allPts) {
if (entry.alpha < 0.01) continue;
const lastX = entry.pts[entry.pts.length - 1][0];
if (lastX > maxLiveDotX) maxLiveDotX = lastX;
}
const distToLive = maxLiveDotX - opts.hoverX;
const fadeStart = Math.min(80, layout.chartW * 0.3);
const scrubOpacity =
distToLive < CROSSHAIR_FADE_MIN_PX
? 0
: distToLive >= fadeStart
? opts.scrubAmount
: ((distToLive - CROSSHAIR_FADE_MIN_PX) / (fadeStart - CROSSHAIR_FADE_MIN_PX)) * opts.scrubAmount;
if (scrubOpacity > 0.01) {
drawMultiCrosshair(
ctx,
layout,
palette,
opts.hoverX,
opts.hoverTime,
opts.hoverEntries,
opts.formatValue,
opts.formatTime,
scrubOpacity,
opts.tooltipY,
opts.tooltipOutline,
maxLiveDotX,
);
}
}
}
// ─── Candlestick draw orchestration ────────────────────────────────────────
export interface CandleDrawOptions {
candles: CandlePoint[];
displayCandleWidth: number;
oldCandles: CandlePoint[];
oldWidth: number;
morphT: number; // candle width transition progress (-1 = none)
liveCandle?: CandlePoint;
/** Pre-blend live candle for the dashed close-price line (unaffected by line mode morph) */
closePriceCandle?: CandlePoint;
liveTime: number;
liveBirthAlpha: number;
liveBullBlend: number;
lineModeProg: number;
chartReveal: number;
now_ms: number;
now: number;
pauseProgress: number;
showGrid: boolean;
showPulse: boolean;
scrubAmount: number;
hoverX: number | null;
hoverValue: number | null;
hoverTime: number | null;
hoveredCandle: CandlePoint | null;
formatValue: (v: number) => string;
formatTime: (t: number) => string;
gridState: GridState;
timeAxisState: TimeAxisState;
dt: number;
targetWindowSecs: number;
tooltipY: number;
tooltipOutline: boolean;
// Line data — drawLine handles morphY, alpha, color, dot position
lineVisible: LivelinePoint[];
lineSmoothValue: number;
emptyText?: string;
loadingAlpha: number;
showEmptyOverlay: boolean; // true only when collapsing to empty (not loading, not forward morph)
reducedMotion?: boolean;
}
/**
* Candlestick draw orchestrator — calls each draw module in the correct
* order for candle mode. Pure drawing function, no state management.
*/
export function drawCandleFrame(ctx: CanvasRenderingContext2D, layout: ChartLayout, palette: LivelinePalette, opts: CandleDrawOptions): void {
const { w, h, pad, chartW, chartH } = layout;
const reveal = opts.chartReveal;
// When fully in line mode, delegate entirely to drawLine (same path as
// drawFrame) so transitions are visually identical to line mode.
const fullLineMode = opts.lineModeProg >= 0.99;
// Line presence (lp): during the reveal, the morph line smoothly
// transforms from the loading squiggly into data positions. In candle
// mode it fades much faster (cubed) so candles become dominant early
// and the morphing line never looks like a "line chart."
const revealLine = fullLineMode ? 1 - reveal : (1 - reveal) * (1 - reveal) * (1 - reveal);
const lp = Math.max(opts.lineModeProg, revealLine);
// colorBlend: when reveal drives lp, force grey (loading squiggly color).
// When the user's lineModeProg drives lp, use accent color.
const colorBlend = lp > 0.001 ? opts.lineModeProg / lp : 1;
// Smoothstep helper for staggered reveal
const revealRamp = (start: number, end: number) => {
const t = Math.max(0, Math.min(1, (reveal - start) / (end - start)));
return t * t * (3 - 2 * t);
};
// 1. Grid — fades in (25%–60% of reveal)
const gridAlpha = revealRamp(0.25, 0.6);
if (opts.showGrid && gridAlpha > 0.01) {
ctx.save();
if (gridAlpha < 1) ctx.globalAlpha = gridAlpha;
drawGrid(ctx, layout, palette, opts.formatValue, opts.gridState, opts.dt);
ctx.restore();
}
// 2. Line — morph line that transforms from loading squiggly into data.
// Returns pts for dot position.
let linePts: [number, number][] | undefined;
if (lp > 0.01 && opts.lineVisible.length >= 2) {
const scrubX = opts.scrubAmount > 0.05 ? opts.hoverX : null;
ctx.save();
ctx.globalAlpha = lp;
linePts = drawLine(
ctx,
layout,
palette,
opts.lineVisible,
opts.lineSmoothValue,
opts.now,
opts.lineModeProg > 0.01,
scrubX,
opts.scrubAmount,
opts.chartReveal,
opts.now_ms,
colorBlend,
!fullLineMode,
opts.lineModeProg, // fillScale — fill fades smoothly with line mode transition
);
ctx.restore();
}
// 3. Close price line — fades in (40%–80% of reveal)
// Uses closePriceCandle (pre-blend) so the dashed line isn't affected
// by line mode morph or OHLC collapse.
const closeAlpha = revealRamp(0.4, 0.8);
const closeSource = opts.closePriceCandle ?? opts.liveCandle;
if (closeSource && closeAlpha > 0.01) {
// Candle-colored close line (fades out with lineModeProg)
if (lp < 0.99) {
ctx.save();
ctx.globalAlpha = closeAlpha * (1 - lp);
drawClosePrice(ctx, layout, palette, closeSource, opts.scrubAmount, opts.liveBullBlend);
ctx.restore();
}
// Accent-colored dash line (fades in with lineModeProg)
// Skip when fully in line mode — drawLine draws its own morphing dash
if (lp > 0.01 && !fullLineMode) {
const dashY = layout.toY(closeSource.close);
if (dashY >= pad.top && dashY <= h - pad.bottom) {
ctx.save();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = palette.dashLine;
ctx.lineWidth = 1;
ctx.globalAlpha = closeAlpha * lp * (1 - opts.scrubAmount * 0.2);
ctx.beginPath();
ctx.moveTo(pad.left, dashY);
ctx.lineTo(w - pad.right, dashY);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
}
// 4. Candles — alpha = chartReveal * (1 - lp)
// During reveal, OHLC collapses toward close so candle bodies shrink
// into thin lines before fading out (or grow from thin lines on appear).
const candleAlpha = opts.chartReveal * (1 - lp);
if (candleAlpha > 0.01) {
// OHLC expansion uses smoothstep on reveal — this keeps shape and alpha
// in sync (at 50% visible, candles are ~50% expanded rather than flat).
const ohlcScale = reveal * reveal * (3 - 2 * reveal);
const collapseC = (c: CandlePoint): CandlePoint =>
ohlcScale >= 0.99
? c
: {
time: c.time,
open: c.close + (c.open - c.close) * ohlcScale,
high: c.close + (c.high - c.close) * ohlcScale,
low: c.close + (c.low - c.close) * ohlcScale,
close: c.close,
};
const revealCandles = ohlcScale < 0.99 ? opts.candles.map(collapseC) : opts.candles;
const revealOld = ohlcScale < 0.99 && opts.oldCandles.length > 0 ? opts.oldCandles.map(collapseC) : opts.oldCandles;
ctx.save();
ctx.beginPath();
ctx.rect(pad.left - 1, pad.top, chartW + 2, chartH);
ctx.clip();
const accentCol = lp > 0.01 ? palette.line : undefined;
if (opts.morphT >= 0 && revealOld.length > 0) {
ctx.globalAlpha = (1 - opts.morphT) * candleAlpha;
drawCandlesticks(
ctx,
layout,
revealOld,
opts.oldWidth,
-1,
opts.now_ms,
opts.hoverX ?? 0,
opts.scrubAmount,
1,
-1,
accentCol,
lp,
opts.showPulse && !opts.reducedMotion,
);
ctx.globalAlpha = opts.morphT * candleAlpha;
drawCandlesticks(
ctx,
layout,
revealCandles,
opts.displayCandleWidth,
opts.liveCandle?.time ?? -1,
opts.now_ms,
opts.hoverX ?? 0,
opts.scrubAmount,
opts.liveBirthAlpha,
opts.liveBullBlend,
accentCol,
lp,
opts.showPulse && !opts.reducedMotion,
);
ctx.globalAlpha = 1;
} else {
if (candleAlpha < 1) ctx.globalAlpha = candleAlpha;
drawCandlesticks(
ctx,
layout,
revealCandles,
opts.displayCandleWidth,
opts.liveCandle?.time ?? -1,
opts.now_ms,
opts.hoverX ?? 0,
opts.scrubAmount,
opts.liveBirthAlpha,
opts.liveBullBlend,
accentCol,
lp,
opts.showPulse && !opts.reducedMotion,
);
}
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];
const dotAlpha = (lp - 0.5) * 2 * ((reveal - 0.3) / 0.7);
const showPulse = opts.showPulse && !opts.reducedMotion && lp > 0.8 && reveal > 0.6;
if (dotAlpha > 0.01) {
ctx.save();
ctx.globalAlpha = dotAlpha;
drawDot(ctx, lastPt[0], lastPt[1], palette, showPulse, opts.scrubAmount, opts.now_ms);
ctx.restore();
}
}
// 6. Time axis — fades in (25%–60% of reveal)
const timeAlpha = revealRamp(0.25, 0.6);
if (timeAlpha > 0.01) {
ctx.save();
if (timeAlpha < 1) ctx.globalAlpha = timeAlpha;
drawTimeAxis(ctx, layout, palette, opts.targetWindowSecs, opts.targetWindowSecs, opts.formatTime, opts.timeAxisState, opts.dt);
ctx.restore();
}
// 7. Left edge fade — gradient erase
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const fadeGrad = ctx.createLinearGradient(pad.left, 0, pad.left + FADE_EDGE_WIDTH, 0);
fadeGrad.addColorStop(0, 'rgba(0, 0, 0, 1)');
fadeGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = fadeGrad;
ctx.fillRect(0, 0, pad.left + FADE_EDGE_WIDTH, h);
ctx.restore();
// 8. Reverse morph empty overlay — only when collapsing to empty state
// (not during forward morph or loading), matching line mode's
// `revealTarget === 0 && !cfg.loading` guard.
if (opts.showEmptyOverlay) {
const bgAlpha = 1 - opts.chartReveal;
if (bgAlpha > 0.01) {
const bgEmptyAlpha = (1 - opts.loadingAlpha) * bgAlpha;
if (bgEmptyAlpha > 0.01) {
drawEmpty(ctx, w, h, pad, palette, bgEmptyAlpha, opts.now_ms, true, opts.emptyText);
}
}
}
// 9. Crosshair — only when mostly revealed (70%+)
if (opts.chartReveal > 0.7 && opts.hoverX !== null && opts.scrubAmount > 0.01) {
if (opts.lineModeProg > 0.5 && opts.hoverValue !== null) {
drawLineModeCrosshair(
ctx,
layout,
palette,
opts.hoverX,
opts.hoverValue,
opts.hoverTime ?? 0,
opts.formatValue,
opts.formatTime,
opts.scrubAmount,
opts.tooltipY,
opts.tooltipOutline,
);
} else if (opts.lineModeProg <= 0.5 && opts.hoveredCandle) {
drawCandleCrosshair(
ctx,
layout,
palette,
opts.hoverX,
opts.hoveredCandle,
opts.hoverTime ?? 0,
opts.formatValue,
opts.formatTime,
opts.scrubAmount,
opts.tooltipY,
opts.tooltipOutline,
);
}
}
}
packages/core/src/liveline/draw/line.ts
import type { LivelinePalette, ChartLayout, LivelinePoint } from '../types';
import { drawSpline } from '../math/spline';
import { loadingY, loadingBreath, LOADING_AMPLITUDE_RATIO, LOADING_SCROLL_SPEED } from './loadingShape';
/** Parse a CSS color to [r, g, b, a]. Handles hex, rgb(), rgba(). */
function parseRgba(color: string): [number, number, number, number] {
const hex = color.match(/^#([0-9a-f]{3,8})$/i);
if (hex) {
let h = hex[1];
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 1];
}
const rgba = color.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)/);
if (rgba) return [+rgba[1], +rgba[2], +rgba[3], +rgba[4]];
const rgb = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) return [+rgb[1], +rgb[2], +rgb[3], 1];
return [128, 128, 128, 1];
}
/** Lerp between two CSS colors including alpha. Handles hex, rgb(), rgba(). */
function blendColor(c1: string, c2: string, t: number): string {
if (t <= 0) return c1;
if (t >= 1) return c2;
const [r1, g1, b1, a1] = parseRgba(c1);
const [r2, g2, b2, a2] = parseRgba(c2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
const a = a1 + (a2 - a1) * t;
if (a >= 0.995) return `rgb(${r},${g},${b})`;
return `rgba(${r},${g},${b},${a.toFixed(3)})`;
}
/** Draw the fill gradient + stroke line for a set of points. */
function renderCurve(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
pts: [number, number][],
showFill: boolean,
lineAlpha: number = 1,
fillAlpha: number = 1,
strokeColor?: string,
) {
const { h, pad } = layout;
const baseAlpha = ctx.globalAlpha;
if (showFill && fillAlpha > 0.01) {
ctx.globalAlpha = baseAlpha * fillAlpha;
const grad = ctx.createLinearGradient(0, pad.top, 0, h - pad.bottom);
grad.addColorStop(0, palette.fillTop);
grad.addColorStop(1, palette.fillBottom);
ctx.beginPath();
ctx.moveTo(pts[0][0], h - pad.bottom);
ctx.lineTo(pts[0][0], pts[0][1]);
drawSpline(ctx, pts);
ctx.lineTo(pts[pts.length - 1][0], h - pad.bottom);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
}
ctx.globalAlpha = baseAlpha * lineAlpha;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
drawSpline(ctx, pts);
ctx.strokeStyle = strokeColor ?? palette.line;
ctx.lineWidth = palette.lineWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();
ctx.globalAlpha = baseAlpha;
}
export function drawLine(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
visible: LivelinePoint[],
smoothValue: number,
now: number,
showFill: boolean,
scrubX: number | null,
scrubAmount: number = 0,
chartReveal: number = 1,
now_ms: number = 0,
colorBlend: number = 1,
skipDashLine: boolean = false,
fillScale: number = 1,
) {
const { h, pad, toX, toY, chartW, chartH } = layout;
const incomingAlpha = ctx.globalAlpha;
// Build screen-space points: all historical data stays stable,
// but the LAST data point uses smoothValue for its Y (so big jumps
// animate smoothly instead of snapping). Its X stays at the original
// data time (stable, no per-frame drift — this is what killed jitter).
// Then append the live tip at (now, smoothValue).
// Y coordinates are clamped to chart bounds so the line hugs the edge
// during range transitions instead of getting hard-clipped.
const yMin = pad.top;
const yMax = h - pad.bottom;
const clampY = (y: number) => Math.max(yMin, Math.min(yMax, y));
// During reveal, morph Y positions from the loading squiggly shape toward real data.
// At chartReveal=0 the chart line traces the exact same squiggly as drawLoading/drawEmpty.
// Center-out: the center of the chart resolves first, edges last, so the data
// line appears to bloom outward from the middle.
const centerY = pad.top + chartH / 2;
const amplitude = chartH * LOADING_AMPLITUDE_RATIO;
const scroll = now_ms * LOADING_SCROLL_SPEED;
const morphY =
chartReveal < 1
? (rawY: number, x: number) => {
const t = Math.max(0, Math.min(1, (x - pad.left) / chartW));
const centerDist = Math.abs(t - 0.5) * 2; // 0 at center, 1 at edges
const localReveal = Math.max(0, Math.min(1, (chartReveal - centerDist * 0.4) / 0.6));
const baseY = loadingY(t, centerY, amplitude, scroll);
return baseY + (rawY - baseY) * localReveal;
}
: (rawY: number, _x: number) => rawY;
const pts: [number, number][] = visible.map((p, i) => {
const x = toX(p.time);
const y = i === visible.length - 1 ? morphY(clampY(toY(smoothValue)), x) : morphY(clampY(toY(p.value)), x);
return [x, y];
});
// Tip X: at reveal=0 extends to full chart width (matching loading/empty line),
// at reveal=1 sits at the live dot position. Smooth morph between.
const liveTipX = toX(now);
const fullRightX = pad.left + chartW;
const tipX = chartReveal < 1 ? liveTipX + (fullRightX - liveTipX) * (1 - chartReveal) : liveTipX;
pts.push([tipX, morphY(clampY(toY(smoothValue)), tipX)]);
if (pts.length < 2) return;
// Reveal alphas: at reveal=0, line matches loading/empty brightness (shared breath).
// As reveal increases, line ramps to full. Fill fades in with reveal.
let lineAlpha = 1;
let fillAlpha = fillScale;
if (chartReveal < 1) {
const breath = loadingBreath(now_ms);
lineAlpha = breath + (1 - breath) * chartReveal;
fillAlpha = chartReveal * fillScale;
}
// Blend line color: grey at reveal=0, accent by reveal≈0.3.
// colorBlend scales the accent mix — 0 forces grey (used during reverse morph
// so the line fades to the loading squiggly color instead of flashing blue).
const colorT = Math.min(1, chartReveal * 3) * colorBlend;
const strokeColor = chartReveal < 1 || colorBlend < 1 ? blendColor(palette.gridLabel, palette.line, colorT) : undefined;
const isScrubbing = scrubX !== null;
// Clip line + fill to chart area — during big value jumps the range
// lerps smoothly so the line may extend beyond the chart bounds.
// Clipping keeps it tidy while the range catches up.
ctx.save();
ctx.beginPath();
ctx.rect(pad.left - 1, pad.top, chartW + 2, chartH);
ctx.clip();
if (isScrubbing) {
// Full-opacity portion: clipped to LEFT of scrub point
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, scrubX!, h);
ctx.clip();
renderCurve(ctx, layout, palette, pts, showFill, lineAlpha, fillAlpha, strokeColor);
ctx.restore();
// Dimmed portion: clipped to RIGHT of scrub point
ctx.save();
ctx.beginPath();
ctx.rect(scrubX!, 0, layout.w - scrubX!, h);
ctx.clip();
ctx.globalAlpha = incomingAlpha * (1 - scrubAmount * 0.6);
renderCurve(ctx, layout, palette, pts, showFill, lineAlpha, fillAlpha, strokeColor);
ctx.restore();
} else {
renderCurve(ctx, layout, palette, pts, showFill, lineAlpha, fillAlpha, strokeColor);
}
// Restore from chart-area clip
ctx.restore();
// Dashed current-price line — morphs from center during reveal (fades in late,
// so the center-vs-squiggly difference is imperceptible by the time it's visible)
if (!skipDashLine) {
const realCurrentY = Math.max(pad.top, Math.min(h - pad.bottom, toY(smoothValue)));
const currentY = chartReveal < 1 ? centerY + (realCurrentY - centerY) * chartReveal : realCurrentY;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = palette.dashLine;
ctx.lineWidth = 1;
const dashBase = isScrubbing ? 1 - scrubAmount * 0.2 : 1;
ctx.globalAlpha = incomingAlpha * (chartReveal < 1 ? dashBase * chartReveal : dashBase);
ctx.beginPath();
ctx.moveTo(pad.left, currentY);
ctx.lineTo(layout.w - pad.right, currentY);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.globalAlpha = incomingAlpha;
// Clamp last point Y so dot stays within canvas (not chart area).
// The dot outer circle is 6.5px + shadow — 10px margin keeps it visible.
const last = pts[pts.length - 1];
last[1] = Math.max(10, Math.min(h - 10, last[1]));
return pts;
}
packages/core/src/liveline/draw/loading.ts
import type { LivelinePalette, Padding } from '../types';
import { drawSpline } from '../math/spline';
import { loadingY, loadingBreath, LOADING_AMPLITUDE_RATIO, LOADING_SCROLL_SPEED } from './loadingShape';
/**
* Draw the loading state: a gently undulating line in accent color at
* breathing alpha. Uses drawSpline (same renderer as the chart line)
* through evenly-spaced samples of loadingY — guarantees the loading
* line and chart morph base produce visually identical curves.
*/
export function drawLoading(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
pad: Required<Padding>,
palette: LivelinePalette,
now_ms: number,
alpha: number = 1,
strokeColor?: string,
): void {
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const centerY = pad.top + chartH / 2;
const leftX = pad.left;
const amplitude = chartH * LOADING_AMPLITUDE_RATIO;
const scroll = now_ms * LOADING_SCROLL_SPEED;
const breath = loadingBreath(now_ms);
// Sample the squiggly at ~32 evenly-spaced points — same density as
// typical chart data, and rendered through the same spline path so the
// loading→chart handoff has zero visual shape difference.
const numPts = 32;
const pts: [number, number][] = [];
for (let i = 0; i <= numPts; i++) {
const t = i / numPts;
const x = leftX + t * chartW;
const y = loadingY(t, centerY, amplitude, scroll);
pts.push([x, y]);
}
ctx.save();
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
drawSpline(ctx, pts);
ctx.strokeStyle = strokeColor ?? palette.line;
ctx.lineWidth = palette.lineWidth;
ctx.globalAlpha = breath * alpha;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
ctx.restore();
}
packages/core/src/liveline/draw/loadingShape.ts
/**
* Shared squiggly line shape and breathing alpha used by both the loading
* state and the chart morph transition. Keeping it in one place guarantees
* the loading line and chart's starting shape + brightness are identical.
*/
export const LOADING_AMPLITUDE_RATIO = 0.07;
export const LOADING_SCROLL_SPEED = 0.001;
/**
* Returns the squiggly Y position for a loading line.
* @param t Normalized x position across chart width (0–1)
* @param centerY Vertical center of the chart area
* @param amplitude Wave height in pixels (chartH * LOADING_AMPLITUDE_RATIO)
* @param scroll Time-based scroll offset (now_ms * LOADING_SCROLL_SPEED)
*/
export function loadingY(t: number, centerY: number, amplitude: number, scroll: number): number {
return centerY + amplitude * (Math.sin(t * 9.4 + scroll) * 0.55 + Math.sin(t * 15.7 + scroll * 1.3) * 0.3 + Math.sin(t * 4.2 + scroll * 0.7) * 0.15);
}
/** Breathing alpha for the loading line and chart line at reveal=0. */
export function loadingBreath(now_ms: number): number {
return 0.22 + 0.08 * Math.sin((now_ms / 1200) * Math.PI);
}
packages/core/src/liveline/draw/orderbook.ts
import type { LivelinePalette, ChartLayout, OrderbookData } from '../types';
// Green: rgb(34, 197, 94), Red: rgb(239, 68, 68)
const GREEN: [number, number, number] = [34, 197, 94];
const RED: [number, number, number] = [239, 68, 68];
interface StreamLabel {
y: number;
text: string;
green: boolean;
life: number;
maxLife: number;
intensity: number; // 0-1, bigger orders = brighter
}
export interface OrderbookState {
labels: StreamLabel[];
spawnTimer: number;
smoothSpeed: number;
// Orderbook churn tracking
prevBidTotal: number;
prevAskTotal: number;
churnRate: number; // smoothed 0-1, how much the book is changing
}
export function createOrderbookState(): OrderbookState {
return {
labels: [],
spawnTimer: 0,
smoothSpeed: BASE_SPEED,
prevBidTotal: 0,
prevAskTotal: 0,
churnRate: 0,
};
}
const MAX_LABELS = 50;
const LABEL_LIFETIME = 6; // seconds
const SPAWN_INTERVAL = 40; // ms
const MIN_LABEL_GAP = 22; // px
const BASE_SPEED = 60; // px/s calm
const MAX_SPEED = 160; // px/s during big activity
function mixColor(from: [number, number, number], to: [number, number, number], t: number): string {
const r = Math.round(from[0] + (to[0] - from[0]) * t);
const g = Math.round(from[1] + (to[1] - from[1]) * t);
const b = Math.round(from[2] + (to[2] - from[2]) * t);
return `rgb(${r},${g},${b})`;
}
/**
* Kalshi-style orderbook: left-aligned column spanning full chart height.
* Labels decelerate as they rise — fast entry at bottom, slow drift at top.
* Speed driven by two signals:
* 1. swingMagnitude — price momentum (proxy for activity)
* 2. orderbook churn — how much the bid/ask data itself is changing
* Whichever signal is stronger wins. Works with both demo and production data.
*/
export function drawOrderbook(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
orderbook: OrderbookData,
dt: number,
state: OrderbookState,
swingMagnitude: number,
): void {
const { pad, h, chartH } = layout;
const dtSec = dt / 1000;
if (orderbook.bids.length === 0 && orderbook.asks.length === 0) return;
let maxSize = 0;
let bidTotal = 0;
let askTotal = 0;
for (const [, size] of orderbook.bids) {
bidTotal += size;
if (size > maxSize) maxSize = size;
}
for (const [, size] of orderbook.asks) {
askTotal += size;
if (size > maxSize) maxSize = size;
}
if (maxSize === 0) return;
// Measure orderbook churn: how much total size changed since last frame
// Normalized by the total size so it's scale-independent
const prevTotal = state.prevBidTotal + state.prevAskTotal;
let churnSignal = 0;
if (prevTotal > 0) {
const delta = Math.abs(bidTotal - state.prevBidTotal) + Math.abs(askTotal - state.prevAskTotal);
churnSignal = Math.min(delta / prevTotal, 1); // 0-1
}
state.prevBidTotal = bidTotal;
state.prevAskTotal = askTotal;
// Smooth the churn rate (fast attack, slower decay)
const churnLerp = churnSignal > state.churnRate ? 0.3 : 0.05;
state.churnRate += (churnSignal - state.churnRate) * churnLerp;
// Activity = max of price momentum and orderbook churn
const activity = Math.max(Math.min(swingMagnitude * 5, 1), state.churnRate);
// Drive speed from activity
const targetSpeed = BASE_SPEED + activity * (MAX_SPEED - BASE_SPEED);
const speedLerp = 1 - Math.pow(0.95, dt / 16.67);
state.smoothSpeed += (targetSpeed - state.smoothSpeed) * speedLerp;
const speed = state.smoothSpeed;
const labelX = pad.left + 8;
const bottomY = h - pad.bottom - 6;
const topY = pad.top;
const bg = palette.bgRgb;
// Spawn new labels at bottom
state.spawnTimer += dt;
while (state.spawnTimer >= SPAWN_INTERVAL && state.labels.length < MAX_LABELS) {
state.spawnTimer -= SPAWN_INTERVAL;
// Check overlap against ALL existing labels near spawn point
let tooClose = false;
for (let j = 0; j < state.labels.length; j++) {
if (Math.abs(state.labels[j].y - bottomY) < MIN_LABEL_GAP) {
tooClose = true;
break;
}
}
if (tooClose) break;
// Weighted random pick
const allLevels: { size: number; green: boolean }[] = [];
for (const [, size] of orderbook.bids) allLevels.push({ size, green: true });
for (const [, size] of orderbook.asks) allLevels.push({ size, green: false });
let totalWeight = 0;
for (const l of allLevels) totalWeight += l.size;
let r = Math.random() * totalWeight;
let picked = allLevels[0];
for (const l of allLevels) {
r -= l.size;
if (r <= 0) {
picked = l;
break;
}
}
const sizeRatio = picked.size / maxSize;
state.labels.push({
y: bottomY,
text: `+ ${formatSize(picked.size)}`,
green: picked.green,
life: LABEL_LIFETIME,
maxLife: LABEL_LIFETIME,
intensity: 0.5 + sizeRatio * 0.5,
});
}
// Update positions — decelerate as labels rise (fast at bottom, slow at top)
const range = bottomY - topY;
let writeIdx = 0;
for (let i = 0; i < state.labels.length; i++) {
const l = state.labels[i];
l.life -= dtSec;
if (l.life <= 0) continue;
const yProgress = range > 0 ? (l.y - topY) / range : 1; // 1 at bottom, 0 at top
l.y -= speed * (0.7 + 0.3 * yProgress) * dtSec;
if (l.y < topY - 14) continue;
state.labels[writeIdx++] = l;
}
state.labels.length = writeIdx;
// Draw
const baseAlpha = ctx.globalAlpha;
ctx.save();
ctx.font = '600 13px "SF Mono", Menlo, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.globalAlpha = baseAlpha;
const outlineColor = `rgb(${bg[0]},${bg[1]},${bg[2]})`;
for (let i = 0; i < state.labels.length; i++) {
const l = state.labels[i];
const lifeRatio = l.life / l.maxLife;
// Fade in quickly, fade out near top of chart
const fadeIn = Math.min((1 - lifeRatio) * 10, 1);
const yRatio = (l.y - topY) / chartH;
const fadeOut = yRatio < 0.45 ? yRatio / 0.45 : 1;
const colorStrength = l.intensity * fadeIn * fadeOut;
const baseColor = l.green ? GREEN : RED;
const fillColor = mixColor(baseColor, bg, 1 - colorStrength);
ctx.strokeStyle = outlineColor;
ctx.lineWidth = 4;
ctx.lineJoin = 'round';
ctx.strokeText(l.text, labelX, l.y);
ctx.fillStyle = fillColor;
ctx.fillText(l.text, labelX, l.y);
}
ctx.restore();
}
function formatSize(size: number): string {
if (size >= 10) return `$${Math.round(size)}`;
if (size >= 1) return `$${size.toFixed(1)}`;
return `$${size.toFixed(2)}`;
}
packages/core/src/liveline/draw/particles.ts
import type { Momentum, DegenOptions } from '../types';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number; // 0-1, starts at 1
size: number;
color: string;
}
export interface ParticleState {
particles: Particle[];
cooldown: number; // ms remaining before next burst
burstCount: number; // consecutive fires — resets when magnitude drops below threshold
}
export function createParticleState(): ParticleState {
return { particles: [], cooldown: 0, burstCount: 0 };
}
const MAX_PARTICLES = 80;
const PARTICLE_LIFETIME = 1.0; // seconds
const COOLDOWN_MS = 400;
const MAGNITUDE_THRESHOLD = 0.08; // fire when swing > 8% of visible range
const MAX_BURSTS = 3; // max consecutive fires before requiring a calm period
/**
* Spawn particles on large upward swings. Returns the burst intensity
* (0 = didn't fire, 0-1 = falloff) so the caller can scale shake.
*
* Small, fast-moving dots that disperse widely from the live dot position.
* Accent-colored with alpha fade.
*/
export function spawnOnSwing(
state: ParticleState,
momentum: Momentum,
dotX: number,
dotY: number,
swingMagnitude: number,
accentColor: string,
dt: number,
options?: DegenOptions,
): number {
state.cooldown = Math.max(0, state.cooldown - dt);
// Below threshold — reset burst counter (calm period)
if (momentum === 'flat' || swingMagnitude < MAGNITUDE_THRESHOLD) {
state.burstCount = 0;
return 0;
}
if (state.cooldown > 0) return 0;
// Down-momentum disabled by default
if (momentum === 'down' && options?.downMomentum !== true) return 0;
// Burst limiter — max consecutive fires, resets on calm
if (state.burstCount >= MAX_BURSTS) return 0;
state.cooldown = COOLDOWN_MS;
const scale = options?.scale ?? 1;
const isUp = momentum === 'up';
// Burst falloff — first burst is biggest, subsequent taper off.
// Big swings (mag > 0.6) override the falloff so they always feel impactful.
const mag = Math.min(swingMagnitude * 5, 1);
const burstFalloff = mag > 0.6 ? 1 : ([1, 0.6, 0.35][state.burstCount] ?? 0.35);
state.burstCount++;
const count = Math.round((12 + mag * 20) * scale * burstFalloff);
const speedMultiplier = 1.0 + mag * 0.8;
for (let i = 0; i < count && state.particles.length < MAX_PARTICLES; i++) {
// Wide burst — almost a full semicircle for maximum dispersal
const baseAngle = isUp ? -Math.PI / 2 : Math.PI / 2;
const spread = Math.PI * 1.2;
const angle = baseAngle + (Math.random() - 0.5) * spread;
const speed = (60 + Math.random() * 100) * speedMultiplier;
state.particles.push({
x: dotX + (Math.random() - 0.5) * 24,
y: dotY + (Math.random() - 0.5) * 8,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1,
size: (1 + Math.random() * 1.2) * scale * burstFalloff,
color: accentColor,
});
}
return burstFalloff;
}
/**
* Update and draw particles.
*/
export function drawParticles(ctx: CanvasRenderingContext2D, state: ParticleState, dt: number): void {
if (state.particles.length === 0) return;
const dtSec = dt / 1000;
ctx.save();
let writeIdx = 0;
for (let i = 0; i < state.particles.length; i++) {
const p = state.particles[i];
p.life -= dtSec / PARTICLE_LIFETIME;
if (p.life <= 0) continue;
p.x += p.vx * dtSec;
p.y += p.vy * dtSec;
p.vx *= 0.95; // less drag — particles travel further
p.vy *= 0.95;
ctx.globalAlpha = p.life * 0.55;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (0.5 + p.life * 0.5), 0, Math.PI * 2);
ctx.fill();
state.particles[writeIdx++] = p;
}
state.particles.length = writeIdx;
ctx.restore();
}
packages/core/src/liveline/draw/referenceLine.ts
import type { LivelinePalette, ChartLayout, ReferenceLine } from '../types';
export function drawReferenceLine(ctx: CanvasRenderingContext2D, layout: ChartLayout, palette: LivelinePalette, ref: ReferenceLine) {
const { w, h, pad, toY, chartW } = layout;
const y = toY(ref.value);
if (y < pad.top - 10 || y > h - pad.bottom + 10) return;
const label = ref.label ?? '';
if (label) {
ctx.font = '500 11px system-ui, sans-serif';
const textW = ctx.measureText(label).width;
const centerX = pad.left + chartW / 2;
const gapPad = 8;
// Line left of text
ctx.strokeStyle = palette.refLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(centerX - textW / 2 - gapPad, y);
ctx.stroke();
// Line right of text
ctx.beginPath();
ctx.moveTo(centerX + textW / 2 + gapPad, y);
ctx.lineTo(w - pad.right, y);
ctx.stroke();
// Label
ctx.fillStyle = palette.refLabel;
ctx.textAlign = 'center';
ctx.fillText(label, centerX, y + 4);
} else {
// Full line, no label
ctx.strokeStyle = palette.refLine;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(w - pad.right, y);
ctx.stroke();
ctx.setLineDash([]);
}
}
packages/core/src/liveline/draw/timeAxis.ts
import type { LivelinePalette, ChartLayout } from '../types';
import { niceTimeInterval } from '../math/intervals';
import { lerp } from '../math/lerp';
export interface TimeAxisState {
labels: Map<number, { alpha: number; text: string }>;
}
const FADE = 0.08;
export function drawTimeAxis(
ctx: CanvasRenderingContext2D,
layout: ChartLayout,
palette: LivelinePalette,
windowSecs: number,
targetWindowSecs: number,
formatTime: (t: number) => string,
state: TimeAxisState,
dt: number,
) {
const { h, pad, leftEdge, rightEdge, toX } = layout;
const chartLeft = pad.left;
const chartRight = layout.w - pad.right;
const chartW = chartRight - chartLeft;
const fadeZone = 50;
const edgeAlpha = (x: number): number => {
const fromLeft = x - chartLeft;
const fromRight = chartRight - x;
const fromEdge = Math.min(fromLeft, fromRight);
if (fromEdge >= fadeZone) return 1;
if (fromEdge <= 0) return 0;
return fromEdge / fadeZone;
};
ctx.font = palette.labelFont;
// Interval fully derived from target window — no dependency on the
// interpolating display. Prevents a one-frame flicker when the transition
// ends and windowSecs snaps to targetWindowSecs.
const targetPxPerSec = chartW / targetWindowSecs;
let interval = niceTimeInterval(targetWindowSecs);
while (interval * targetPxPerSec < 60 && interval < targetWindowSecs) {
interval *= 2;
}
// Generate labels: current view + 1 interval buffer.
// Cap at 30 labels as a safety valve — during wide→narrow transitions the
// target interval can be tiny relative to the current display span.
// For day+ intervals, align to local midnight instead of UTC epoch.
const useLocalDays = interval >= 86400;
let firstTime: number;
if (useLocalDays) {
const d = new Date((leftEdge - interval) * 1000);
d.setHours(0, 0, 0, 0);
firstTime = d.getTime() / 1000;
} else {
firstTime = Math.ceil((leftEdge - interval) / interval) * interval;
}
const targets = new Set<number>();
for (let t = firstTime; t <= rightEdge + interval && targets.size < 30; t += interval) {
targets.add(Math.round(t * 100));
}
// Create or update labels. Text is updated in-place — no crossfade needed
// because format changes coincide with scroll transitions where the eye
// tracks movement, not text content. By the time labels settle, the text
// is already correct so nothing visibly changes on stationary labels.
for (const key of targets) {
const text = formatTime(key / 100);
const existing = state.labels.get(key);
if (!existing) {
state.labels.set(key, { alpha: 0, text });
} else {
existing.text = text;
}
}
// Update alphas
for (const [key, label] of state.labels) {
const x = toX(key / 100);
const isTarget = targets.has(key);
const target = isTarget ? edgeAlpha(x) : 0;
let next = lerp(label.alpha, target, FADE, dt);
if (Math.abs(next - target) < 0.02) next = target;
if (next < 0.01 && target === 0) {
state.labels.delete(key);
} else {
label.alpha = next;
}
}
// Draw
const baseAlpha = ctx.globalAlpha;
const lineY = h - pad.bottom;
const tickLen = 5;
ctx.strokeStyle = palette.gridLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(chartLeft, lineY);
ctx.lineTo(chartRight, lineY);
ctx.stroke();
ctx.textAlign = 'center';
// Collect, sort by X, resolve overlaps by keeping the more-visible label
const labels: { x: number; alpha: number; text: string; w: number }[] = [];
for (const [key, label] of state.labels) {
if (label.alpha < 0.02) continue;
const x = toX(key / 100);
if (x < chartLeft - 20 || x > chartRight) continue;
const w = ctx.measureText(label.text).width;
labels.push({ x, alpha: label.alpha, text: label.text, w });
}
labels.sort((a, b) => a.x - b.x);
// Resolve overlaps: when two labels collide, keep the higher-alpha one.
// This gives a clean one-time crossover (no flickering) because one alpha
// is always rising while the other is falling.
const drawn: typeof labels = [];
for (const label of labels) {
const left = label.x - label.w / 2;
if (drawn.length > 0) {
const prev = drawn[drawn.length - 1];
const prevRight = prev.x + prev.w / 2;
if (left < prevRight + 8) {
// Overlap — swap in the higher-alpha label
if (label.alpha > prev.alpha) {
drawn[drawn.length - 1] = label;
}
continue;
}
}
drawn.push(label);
}
for (const label of drawn) {
ctx.save();
ctx.globalAlpha = baseAlpha * label.alpha;
ctx.strokeStyle = palette.gridLine;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(label.x, lineY);
ctx.lineTo(label.x, lineY + tickLen);
ctx.stroke();
ctx.fillStyle = palette.timeLabel;
ctx.fillText(label.text, label.x, lineY + tickLen + 14);
ctx.restore();
}
}
packages/core/src/liveline/math/interpolate.ts
import type { LivelinePoint } from '../types';
/**
* Binary search to find interpolated value at a given time.
* Returns null if time is outside data range.
*/
export function interpolateAtTime(points: LivelinePoint[], time: number): number | null {
if (points.length === 0) return null;
if (time <= points[0].time) return points[0].value;
if (time >= points[points.length - 1].time) return points[points.length - 1].value;
// Binary search for the interval containing `time`
let lo = 0;
let hi = points.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (points[mid].time <= time) lo = mid;
else hi = mid;
}
const p1 = points[lo];
const p2 = points[hi];
const dt = p2.time - p1.time;
if (dt === 0) return p1.value;
const t = (time - p1.time) / dt;
return p1.value + (p2.value - p1.value) * t;
}
packages/core/src/liveline/math/intervals.ts
/** Pick a nice time interval in seconds for time axis labels. */
export function niceTimeInterval(windowSecs: number): number {
if (windowSecs <= 15) return 2;
if (windowSecs <= 30) return 5;
if (windowSecs <= 60) return 10;
if (windowSecs <= 120) return 15;
if (windowSecs <= 300) return 30;
if (windowSecs <= 600) return 60; // 10min → 1min ticks
if (windowSecs <= 1800) return 300; // 30min → 5min ticks
if (windowSecs <= 3600) return 600; // 1hr → 10min ticks
if (windowSecs <= 14400) return 1800; // 4hr → 30min ticks
if (windowSecs <= 43200) return 3600; // 12hr → 1hr ticks
if (windowSecs <= 86400) return 7200; // 1day → 2hr ticks
if (windowSecs <= 604800) return 86400; // 1week → 1day ticks
return 604800; // beyond → 1week ticks
}
packages/core/src/liveline/math/lerp.ts
/**
* Frame-rate-independent exponential lerp.
* `speed` is the fraction approached per 16.67ms (60fps frame).
* At lower frame rates, dt is larger so we approach more per frame.
*/
export function lerp(current: number, target: number, speed: number, dt = 16.67): number {
// Convert per-frame speed to continuous decay factor
const factor = 1 - Math.pow(1 - Math.max(0, Math.min(1, speed)), Math.max(0, dt) / 16.67);
return current + (target - current) * factor;
}
packages/core/src/liveline/math/momentum.ts
import type { Momentum, LivelinePoint } from '../types';
/**
* Auto-detect momentum from recent data points.
* Only triggers during active movement — checks the last few points,
* not the total delta over the full lookback window.
*/
export function detectMomentum(points: LivelinePoint[], lookback = 20): Momentum {
if (points.length < 5) return 'flat';
const start = Math.max(0, points.length - lookback);
// Range of the full lookback for threshold calculation
let min = Infinity;
let max = -Infinity;
for (let i = start; i < points.length; i++) {
const v = points[i].value;
if (v < min) min = v;
if (v > max) max = v;
}
const range = max - min;
if (range === 0) return 'flat';
// Only look at the last 5 points for active velocity
const tailStart = Math.max(start, points.length - 5);
const first = points[tailStart].value;
const last = points[points.length - 1].value;
const delta = last - first;
const threshold = range * 0.12;
if (delta > threshold) return 'up';
if (delta < -threshold) return 'down';
return 'flat';
}
packages/core/src/liveline/math/range.ts
import type { LivelinePoint } from '../types';
/**
* Compute visible Y range from data points + current value.
* Returns { min, max } with margin applied.
*/
export function computeRange(visible: LivelinePoint[], currentValue: number, referenceValue?: number, exaggerate?: boolean): { min: number; max: number } {
let targetMin = Infinity;
let targetMax = -Infinity;
for (const p of visible) {
if (!Number.isFinite(p.value)) continue;
if (p.value < targetMin) targetMin = p.value;
if (p.value > targetMax) targetMax = p.value;
}
if (Number.isFinite(currentValue) && currentValue < targetMin) targetMin = currentValue;
if (Number.isFinite(currentValue) && currentValue > targetMax) targetMax = currentValue;
// Include reference line so it's always visible
if (referenceValue !== undefined && Number.isFinite(referenceValue)) {
if (referenceValue < targetMin) targetMin = referenceValue;
if (referenceValue > targetMax) targetMax = referenceValue;
}
if (!Number.isFinite(targetMin) || !Number.isFinite(targetMax)) return { min: -0.2, max: 0.2 };
const rawRange = targetMax - targetMin;
const marginFactor = exaggerate ? 0.01 : 0.12;
const minRange = rawRange * (exaggerate ? 0.02 : 0.1) || (exaggerate ? 0.04 : 0.4);
if (rawRange < minRange) {
const mid = (targetMin + targetMax) / 2;
targetMin = mid - minRange / 2;
targetMax = mid + minRange / 2;
} else {
const margin = rawRange * marginFactor;
targetMin -= margin;
targetMax += margin;
}
return { min: targetMin, max: targetMax };
}
packages/core/src/liveline/math/spline.ts
/**
* Fritsch-Carlson monotone cubic interpolation.
* Guarantees no overshoots — the curve never exceeds local min/max.
* Used by Chart.js (monotone mode) and D3 (curveMonotoneX).
*
* Continues from current ctx position — caller must moveTo first point.
*/
export function drawSpline(ctx: CanvasRenderingContext2D, pts: [number, number][]) {
if (pts.length < 2) return;
if (pts.length === 2) {
ctx.lineTo(pts[1][0], pts[1][1]);
return;
}
const n = pts.length;
// 1. Compute secant slopes (delta) between consecutive points
const delta = Array.from<number>({ length: n - 1 });
const h = Array.from<number>({ length: n - 1 }); // x-intervals
for (let i = 0; i < n - 1; i++) {
h[i] = pts[i + 1][0] - pts[i][0];
delta[i] = h[i] === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / h[i];
}
// 2. Initial tangent estimates
const m = Array.from<number>({ length: n });
m[0] = delta[0];
m[n - 1] = delta[n - 2];
for (let i = 1; i < n - 1; i++) {
if (delta[i - 1] * delta[i] <= 0) {
// Sign change or zero — tangent must be zero for monotonicity
m[i] = 0;
} else {
m[i] = (delta[i - 1] + delta[i]) / 2;
}
}
// 3. Fritsch-Carlson constraint: alpha^2 + beta^2 <= 9
for (let i = 0; i < n - 1; i++) {
if (delta[i] === 0) {
// Flat segment — zero both endpoint tangents
m[i] = 0;
m[i + 1] = 0;
} else {
const alpha = m[i] / delta[i];
const beta = m[i + 1] / delta[i];
const s2 = alpha * alpha + beta * beta;
if (s2 > 9) {
const s = 3 / Math.sqrt(s2);
m[i] = s * alpha * delta[i];
m[i + 1] = s * beta * delta[i];
}
}
}
// 4. Draw bezier curves using tangents as control points
for (let i = 0; i < n - 1; i++) {
const hi = h[i];
ctx.bezierCurveTo(pts[i][0] + hi / 3, pts[i][1] + (m[i] * hi) / 3, pts[i + 1][0] - hi / 3, pts[i + 1][1] - (m[i + 1] * hi) / 3, pts[i + 1][0], pts[i + 1][1]);
}
}