shadimage
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]);
	}
}

More from Awesome Cursor Rules

Browse all components in this library →