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