BTC-Mini-Terminal

JavaScript

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         BTC-Mini-Terminal
// @namespace
// @version      1.1
// @description  JavaScript
// @author       CHN-DST (modified)
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.binance.com
// @connect      fxhapi.feixiaohao.com
// @connect      api.huobi.pro
// @connect      api.coingecko.com
// @run-at       document-idle
// @license MIT
// @namespace https://greasyfork.org/users/1548267
// ==/UserScript==

(function () {
  'use strict';

  // ---------- 注入检查 ----------
  try {
    if (window.top !== window.self) return;
    const EXCLUDE_HOSTNAME_PATTERNS = [
      /doubleclick\.net/i, /googlesyndication\.com/i, /google-analytics\.com/i,
      /adservice\.google\.com/i, /ads\.youtube\.com/i, /adsrvr\.org/i
    ];
    const hostname = (location.hostname || '').toLowerCase();
    if (EXCLUDE_HOSTNAME_PATTERNS.some(r => r.test(hostname))) return;
  } catch (e) { /* ignore */ }

  // ---------- 配置 ----------
  const SYMBOL = 'BTCUSDT';
  const FIAT = 'usd';
  const WIDTH = 370;
  const HEIGHT = 230;
  const PRICE_REFRESH_MS = 5000;
  const DEFAULT_INTERVAL = '1m';

  const CANDLE_OPTIONS = [50, 100, 200];
  const CANDLE_KEY = 'btc_widget_candles';
  const COLLAPSED_KEY = 'btc_widget_collapsed';
  const PROXY_KEY = 'btc_widget_proxy';
  const PUBLIC_PROXY_PREFIXES = [
    'https://api.allorigins.win/raw?url=',
    'https://thingproxy.freeboard.io/fetch/',
    'https://thingproxy.org/fetch/'
  ];

  // ---------- DOM / 状态  (will be assigned during init) ----------
  let container = null;
  let canvas = null;
  let ctx = null;
  let tooltip = null;

  let currentInterval = DEFAULT_INTERVAL;
  let priceTimer = null;
  let klineTimer = null;
  let lastPrice = null;
  let selectedCandleCount = 200;

  // ---------- style ----------
  const style = document.createElement('style');
  style.textContent = `
  #btc-widget {
    position: fixed;
    left: 12px;
    bottom: 12px;
    width: ${WIDTH}px;
    box-shadow: 0 8px 30px rgba(0,0,0,0.25);
    border-radius: 10px;
    overflow: hidden;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
    z-index: 2147483647;
    background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(245,245,247,0.96));
    color: #111;
    user-select: none;
  }
  #btc-widget.dark {
    background: linear-gradient(180deg, rgba(18,18,20,0.96), rgba(12,12,14,0.96));
    color: #e8e8e8;
  }

  #btc-header { display:flex; align-items:center; justify-content:space-between; padding:8px 10px; cursor: move; gap:8px; white-space:nowrap; min-width:0; overflow:hidden; }
  #btc-header .left { display:flex; align-items:center; gap:8px; white-space:nowrap; min-width:0; overflow:hidden; }
  #btc-symbol { font-weight:700; font-size:13px; white-space:nowrap; flex:0 0 auto; }
  #btc-price { font-weight:700; font-size:15px; white-space:nowrap; flex:0 1 auto; min-width:56px; max-width:140px; overflow:hidden; text-overflow:ellipsis; }
  #btc-pct { font-size:12px; padding:2px 6px; border-radius:6px; flex:0 0 auto; }

  #btc-controls { display:flex; gap:6px; align-items:center; flex:0 0 auto; }
  .btc-btn { font-size:12px; padding:6px 8px; border-radius:6px; border:1px solid rgba(0,0,0,0.06); background:transparent; cursor:pointer; }
  .count-btn { font-size:12px; padding:4px 6px; border-radius:6px; border:1px solid rgba(0,0,0,0.06); background:transparent; cursor:pointer; }

  #btc-footer { display:flex; align-items:center; justify-content:space-between; padding:6px 10px; font-size:12px; color:#666; position: relative; }
  #btc-counts { position:absolute; left:50%; transform:translateX(-50%); bottom:6px; display:flex; gap:6px; align-items:center; pointer-events:auto; }
  #btc-chart-wrap { padding:6px 8px 12px 8px; display:block; }
  #btc-canvas { width:100%; height:140px; background:transparent; display:block; border-radius:6px; }
  #btc-minimap { margin-top:6px; font-size:11px; color:#666; text-align:right; }
  #btc-toggle { font-size:12px; padding:6px; border-radius:6px; cursor:pointer; }

  #btc-widget.collapsed #btc-chart-wrap, #btc-widget.collapsed #btc-footer { display:none; }
  #btc-widget.collapsed #btc-counts { display:none !important; }

  #btc-error { color:#b91c1c; font-size:12px; margin-top:6px; text-align:left; white-space:pre-wrap; word-break:break-word; }

  #btc-price-tooltip {
    position: fixed;
    z-index: 2147483650;
    padding:6px 8px;
    border-radius:6px;
    background: rgba(0,0,0,0.85);
    color: #fff;
    font-size:13px;
    pointer-events: none;
    white-space: nowrap;
    transform: translateY(-6px);
    display: none;
    box-shadow: 0 6px 18px rgba(0,0,0,0.3);
  }
  #btc-price-tooltip.light {
    background: rgba(255,255,255,0.95);
    color: #111;
    border: 1px solid rgba(0,0,0,0.06);
  }
  `;
  document.head.appendChild(style);

  // ---------- 存储 helpers (GM优先,回退到localStorage) ----------
  async function getStored(key, defaultVal = null) {
    try {
      if (typeof GM_getValue === 'function') {
        // GM_getValue might be sync or return a value or a promise - handle both
        const maybe = GM_getValue(key);
        if (maybe && typeof maybe.then === 'function') {
          const v = await maybe;
          if (typeof v !== 'undefined' && v !== null) return v;
        } else {
          if (typeof maybe !== 'undefined' && maybe !== null) return maybe;
        }
      }
    } catch (e) { /* ignore GM read error */ }

    try {
      const ls = localStorage.getItem(key);
      if (ls !== null) {
        // try parse numbers/booleans if appropriate
        if (ls === 'true') return true;
        if (ls === 'false') return false;
        if (!isNaN(Number(ls))) return Number(ls);
        return ls;
      }
    } catch (e) {}
    return defaultVal;
  }

  async function setStored(key, val) {
    try {
      if (typeof GM_setValue === 'function') {
        const ret = GM_setValue(key, val);
        if (ret && typeof ret.then === 'function') await ret;
        return;
      }
    } catch (e) { /* ignore GM write error */ }
    try {
      localStorage.setItem(key, String(val));
    } catch (e) {}
  }

  // ---------- 通用网络 helpers(不变) ----------
  async function tryFetchJson(url) {
    const res = await fetch(url, { cache: 'no-store', credentials: 'omit' });
    if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
    return await res.json();
  }
  async function tryGMJson(url) {
    if (typeof GM_xmlhttpRequest !== 'function') throw new Error('GM_xmlhttpRequest not available');
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        responseType: 'json',
        onload(resp) {
          if (resp.status >= 200 && resp.status < 300) resolve(resp.response);
          else reject(new Error('GM_xmlhttpRequest HTTP ' + resp.status));
        },
        onerror(err) { reject(err); },
        ontimeout() { reject(new Error('GM_xmlhttpRequest timeout')); }
      });
    });
  }
  async function httpGetJson(url) {
    try { return await tryFetchJson(url); } catch (fetchErr) { console.debug('fetch failed', fetchErr); }
    try { return await tryGMJson(url); } catch (gmErr) { console.debug('GM failed', gmErr); }

    try {
      const userProxy = await getStored(PROXY_KEY, '');
      if (userProxy && typeof userProxy === 'string' && userProxy.trim()) {
        const proxied = userProxy.trim() + encodeURIComponent(url);
        try { return await tryFetchJson(proxied); } catch (e1) { try { return await tryGMJson(proxied); } catch (e2) {} }
      }
    } catch (e) { console.debug('proxy check error', e); }

    for (const prefix of PUBLIC_PROXY_PREFIXES) {
      try {
        const proxied = prefix + encodeURIComponent(url);
        try { return await tryFetchJson(proxied); } catch (pf) { try { return await tryGMJson(proxied); } catch (pg) {} }
      } catch (err) { console.debug('public proxy iteration error', err); }
    }

    const hint = '所有直接/代理请求均失败';
    throw new Error('网络请求失败。' + hint);
  }

  // ---------- 数据源实现(保持原样) ----------
  async function fetchPriceFeixiaohao() {
    const url = 'https://fxhapi.feixiaohao.com/public/v1/ticker?limit=5&convert=USD';
    try {
      const json = await httpGetJson(url);
      if (!Array.isArray(json)) throw new Error('Feixiaohao response invalid');
      const btc = json.find(item => (item.id && String(item.id).toLowerCase() === 'bitcoin') || (item.symbol && String(item.symbol).toUpperCase() === 'BTC'));
      if (!btc) throw new Error('Feixiaohao bitcoin entry not found');
      const p = (typeof btc.price_usd !== 'undefined') ? btc.price_usd : (btc.price || btc.price_usd);
      if (typeof p === 'number' || !isNaN(Number(p))) return parseFloat(p);
      throw new Error('Feixiaohao price missing');
    } catch (e) { throw new Error('Feixiaohao price error: ' + (e.message || e)); }
  }

  async function fetchKlinesHuobi(intervalHuobi, size = 200) {
    const base = 'https://api.huobi.pro/market/history/kline';
    const url = `${base}?symbol=btcusdt&period=${intervalHuobi}&size=${size}`;
    try {
      const json = await httpGetJson(url);
      if (!json || (json.status && json.status !== 'ok') || !Array.isArray(json.data)) throw new Error('Huobi response invalid');
      return json.data.map(item => ({ time: Number(item.id), open: parseFloat(item.open), high: parseFloat(item.high), low: parseFloat(item.low), close: parseFloat(item.close) })).reverse();
    } catch (e) { throw new Error('Huobi klines error: ' + (e.message || e)); }
  }

  async function fetchPriceHuobi() {
    const url = `https://api.huobi.pro/market/detail/merged?symbol=btcusdt`;
    try {
      const json = await httpGetJson(url);
      if (json && json.tick && typeof json.tick.close !== 'undefined') return parseFloat(json.tick.close);
      throw new Error('Huobi price missing');
    } catch (e) { throw new Error('Huobi price error: ' + (e.message || e)); }
  }

  async function fetchKlinesBinance(intervalBin, limit = 200) {
    const base = 'https://api.binance.com/api/v3/klines';
    const url = `${base}?symbol=${SYMBOL}&interval=${intervalBin}&limit=${limit}`;
    try {
      const json = await httpGetJson(url);
      return json.map(item => ({ time: Math.floor(item[0] / 1000), open: parseFloat(item[1]), high: parseFloat(item[2]), low: parseFloat(item[3]), close: parseFloat(item[4]) }));
    } catch (e) { throw new Error('Binance klines error: ' + (e.message || e)); }
  }
  async function fetchPriceBinance() {
    const url = `https://api.binance.com/api/v3/ticker/price?symbol=${SYMBOL}`;
    try { const json = await httpGetJson(url); return parseFloat(json.price); } catch (e) { throw new Error('Binance price error: ' + (e.message || e)); }
  }

  async function fetchOhlcCoinGecko(days = 1) {
    const url = `https://api.coingecko.com/api/v3/coins/bitcoin/ohlc?vs_currency=${FIAT}&days=${days}`;
    try {
      const json = await httpGetJson(url);
      return json.map(item => ({ time: Math.floor(item[0] / 1000), open: item[1], high: item[2], low: item[3], close: item[4] }));
    } catch (e) { throw new Error('CoinGecko OHLC error: ' + (e.message || e)); }
  }
  async function fetchMarketChartPricesCoinGecko(days = 1) {
    const url = `https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=${FIAT}&days=${days}`;
    try {
      const json = await httpGetJson(url);
      return (json.prices || []).map(p => ({ time: Math.floor(p[0] / 1000), price: p[1] }));
    } catch (e) { throw new Error('CoinGecko market_chart error: ' + (e.message || e)); }
  }

  // ---------- 聚合/转换 ----------
  function aggregatePricesToCandles(pricePoints, secondsPerCandle, maxCandles = 200) {
    if (!pricePoints || pricePoints.length === 0) return [];
    pricePoints.sort((a, b) => a.time - b.time);
    const firstTime = pricePoints[0].time;
    const buckets = new Map();
    for (const p of pricePoints) {
      const idx = Math.floor((p.time - firstTime) / secondsPerCandle);
      const key = firstTime + idx * secondsPerCandle;
      if (!buckets.has(key)) buckets.set(key, []);
      buckets.get(key).push(p.price);
    }
    const candles = [];
    for (const [t, arr] of buckets) {
      if (!arr || arr.length === 0) continue;
      const open = arr[0];
      const close = arr[arr.length - 1];
      let high = -Infinity, low = Infinity;
      for (const v of arr) { if (v > high) high = v; if (v < low) low = v; }
      candles.push({ time: t, open, high, low, close });
    }
    return candles.slice(-maxCandles);
  }

  const INTERVAL_MAP = {
    '1m': { bin: '1m', huobi: '1min', seconds: 60, binLimit: 200 },
    '1h': { bin: '1h', huobi: '60min', seconds: 3600, binLimit: 200 },
    '1d': { bin: '1d', huobi: '1day', seconds: 86400, binLimit: 365 },
  };

  // ---------- Kline + price fallback (保持原逻辑) ----------
  async function fetchKlinesWithFallback(intervalKey, limitOverride) {
    const cfg = INTERVAL_MAP[intervalKey];
    const limitToUse = Number(limitOverride) || cfg.binLimit;
    try {
      const kl = await fetchKlinesBinance(cfg.bin, limitToUse);
      return { source: 'binance', kl };
    } catch (binErr) {
      try {
        const huobiLimit = Math.min(limitToUse, 200);
        const kl = await fetchKlinesHuobi(cfg.huobi, huobiLimit);
        return { source: 'huobi', kl };
      } catch (huErr) {
        try {
          if (intervalKey === '1d') {
            const ohlc = await fetchOhlcCoinGecko(90);
            const last = ohlc.slice(-Math.min(limitToUse, ohlc.length));
            return { source: 'coingecko_ohlc', kl: last };
          } else {
            const days = intervalKey === '1m' ? 1 : 7;
            const prices = await fetchMarketChartPricesCoinGecko(days);
            const sPer = cfg.seconds;
            const candles = aggregatePricesToCandles(prices, sPer, limitToUse);
            if (candles.length === 0) throw new Error('CoinGecko aggregation returned 0 candles');
            return { source: 'coingecko_prices_agg', kl: candles };
          }
        } catch (cgErr) {
          throw new Error('All sources failed. BinErr: ' + (binErr.message || binErr) + ' ; HuErr: ' + (huErr.message || huErr) + ' ; CGErr: ' + (cgErr.message || cgErr));
        }
      }
    }
  }

  async function fetchPriceWithFallback() {
    try {
      const p = await fetchPriceBinance();
      return { source: 'binance', price: p };
    } catch (binErr) {
      try {
        const pfx = await fetchPriceFeixiaohao();
        return { source: 'feixiaohao', price: pfx };
      } catch (fxhErr) {
        try {
          const p2 = await fetchPriceHuobi();
          return { source: 'huobi', price: p2 };
        } catch (huErr) {
          try {
            const url = `https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${FIAT}`;
            const json = await httpGetJson(url);
            if (json && json.bitcoin && json.bitcoin[FIAT]) return { source: 'coingecko', price: parseFloat(json.bitcoin[FIAT]) };
            throw new Error('CoinGecko price missing');
          } catch (cgErr) {
            throw new Error('Price sources failed. BinErr: ' + (binErr.message || binErr) + ' ; FXHErr: ' + (fxhErr && fxhErr.message || fxhErr) + ' ; HuErr: ' + (huErr && huErr.message || huErr) + ' ; CGErr: ' + (cgErr.message || cgErr));
          }
        }
      }
    }
  }

  // ---------- Grid & 绘图 helper(保持原样,改为使用上层 ctx/canvas 变量) ----------
  function drawGridAndAxis(minPrice, maxPrice, w, h, topPad, bottomPad) {
    ctx.save();
    ctx.globalAlpha = 0.07;
    ctx.strokeStyle = '#000';
    ctx.lineWidth = 1;
    const rows = 4;
    for (let i = 0; i <= rows; i++) {
      const y = topPad + (h - topPad - bottomPad) * (i / rows);
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(w, y);
      ctx.stroke();
    }
    ctx.restore();

    ctx.save();
    ctx.font = '11px system-ui, Arial';
    ctx.fillStyle = getComputedStyle(document.body).color || '#111';
    const labelCount = 4;
    for (let i = 0; i <= labelCount; i++) {
      const y = topPad + (h - topPad - bottomPad) * (i / labelCount);
      const val = (maxPrice - (maxPrice - minPrice) * (i / labelCount));
      const txt = (val >= 1 ? val.toLocaleString(undefined, { maximumFractionDigits: 2 }) : val.toPrecision(6));
      const txtW = ctx.measureText(txt).width;
      const tx = w - txtW - 4;
      const ty = Math.max(topPad + 8, Math.min(h - bottomPad - 2, y + 4));
      ctx.fillText(txt, tx, ty);
    }
    ctx.restore();
  }

  function fitCanvasToDisplaySize() {
    if (!canvas) return;
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    const displayWidth = Math.max(1, canvas.clientWidth || (WIDTH));
    const displayHeight = Math.max(1, canvas.clientHeight || (HEIGHT));
    canvas.style.width = displayWidth + 'px';
    canvas.style.height = displayHeight + 'px';
    const newW = Math.floor(displayWidth * dpr);
    const newH = Math.floor(displayHeight * dpr);
    if (canvas.width !== newW || canvas.height !== newH) {
      canvas.width = newW;
      canvas.height = newH;
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function drawCandles(candles) {
    if (!canvas || !ctx) return;
    const cssWidth = canvas.clientWidth || (canvas.width / Math.max(1, window.devicePixelRatio || 1));
    const cssHeight = canvas.clientHeight || (canvas.height / Math.max(1, window.devicePixelRatio || 1));

    ctx.clearRect(0, 0, cssWidth, cssHeight);

    if (!candles || candles.length === 0) {
      ctx.save();
      ctx.font = '12px system-ui, Arial';
      ctx.fillStyle = '#888';
      ctx.fillText('无可用 K 线数据', 8, 20);
      ctx.restore();
      return;
    }

    let minP = Infinity, maxP = -Infinity;
    for (const c of candles) {
      if (typeof c.low === 'number' && c.low < minP) minP = c.low;
      if (typeof c.high === 'number' && c.high > maxP) maxP = c.high;
    }
    if (!isFinite(minP) || !isFinite(maxP)) return;
    const rawRange = Math.max(1e-12, maxP - minP);

    const padRatio = Math.max(0.01, Math.min(0.18, rawRange / Math.max(Math.abs(maxP), 1)));

    const topPad = Math.max(10, Math.round(padRatio * cssHeight * 0.9 + 8));
    const bottomPad = Math.max(12, Math.round(padRatio * cssHeight * 1.2 + 12));

    const absPad = rawRange > 0 ? rawRange * 0.03 : Math.max(Math.abs(maxP) * 0.01, 1);
    minP -= absPad; maxP += absPad;

    const isDark = container.classList.contains('dark');
    ctx.save();
    ctx.fillStyle = isDark ? 'rgba(10,10,12,0.85)' : 'rgba(255,255,255,0.95)';
    ctx.fillRect(0, 0, cssWidth, cssHeight);
    ctx.restore();

    drawGridAndAxis(minP, maxP, cssWidth, cssHeight, topPad, bottomPad);

    const leftPad = 6, rightPad = 56;
    const drawW = Math.max(1, cssWidth - leftPad - rightPad);
    const drawH = Math.max(8, cssHeight - topPad - bottomPad);

    const maxCandlesVis = Math.floor(drawW / 3) || 1;
    const n = Math.min(candles.length, maxCandlesVis);
    const start = Math.max(0, candles.length - n);
    const slice = candles.slice(start);

    const effectiveN = Math.max(1, n);
    const candleW = Math.max(1, Math.floor((drawW / effectiveN) * 0.6));
    const gap = Math.max(1, Math.floor((drawW - effectiveN * candleW) / (effectiveN + 1)));

    function priceToY(p) {
      if (maxP === minP) return topPad + drawH / 2;
      const y = topPad + ((maxP - p) / (maxP - minP)) * drawH;
      return Math.round(Math.max(topPad + 0.5, Math.min(topPad + drawH - 0.5, y)));
    }

    let x = leftPad + gap;
    for (let i = 0; i < slice.length; i++) {
      const c = slice[i];
      const openY = priceToY(c.open);
      const closeY = priceToY(c.close);
      const highY = priceToY(c.high);
      const lowY = priceToY(c.low);

      const top = Math.min(openY, closeY);
      const bottom = Math.max(openY, closeY);
      const bodyH = Math.max(1, bottom - top);

      const isUp = c.close >= c.open;
      const color = isUp ? '#10B981' : '#ef4444';

      const cx = Math.round(x + Math.floor(candleW / 2));
      const cw = Math.max(1, Math.round(candleW));
      // wick
      ctx.beginPath();
      ctx.strokeStyle = color;
      ctx.lineWidth = Math.max(1, Math.round(cw / 6));
      ctx.moveTo(cx, highY);
      ctx.lineTo(cx, lowY);
      ctx.stroke();

      // body
      const bx = Math.round(x);
      ctx.fillStyle = color;
      ctx.fillRect(bx, top, cw, bodyH);

      x += candleW + gap;
    }

    const last = slice[slice.length - 1];
    if (last) {
      let ly = priceToY(last.close);
      ly = Math.round(Math.max(topPad + 1, Math.min(topPad + drawH - 1, ly)));

      ctx.beginPath();
      ctx.strokeStyle = '#888';
      ctx.setLineDash([4, 3]);
      ctx.moveTo(0, ly);
      ctx.lineTo(cssWidth, ly);
      ctx.stroke();
      ctx.setLineDash([]);

      const text = (last.close >= 1 ? last.close.toLocaleString(undefined, { maximumFractionDigits: 2 }) : last.close.toPrecision(6));
      ctx.font = '12px system-ui, Arial';
      ctx.textBaseline = 'middle';
      const txtW = ctx.measureText(text).width;

      ctx.fillStyle = isDark ? '#e8e8e8' : '#111';
      const textX = Math.max(8, cssWidth - txtW - 10);
      const textY = ly;
      ctx.fillText(text, textX, textY);
    }
  }

  // ---------- Orchestration ----------
  async function updateKlinesAndDraw() {
    setError(null);
    try {
      const res = await fetchKlinesWithFallback(currentInterval, selectedCandleCount);
      const kl = res.kl || [];
      setRangeUI(kl.length);
      setUpdateTimeUI(Date.now(), res.source);
      const prevClose = kl.length >= 2 ? kl[kl.length - 2].close : null;
      setPriceUI(lastPrice || (kl.length ? kl[kl.length - 1].close : null), prevClose);
      fitCanvasToDisplaySize();
      drawCandles(kl);
      document.getElementById('btc-error').style.display = 'none';
    } catch (e) {
      const msg = e && e.message ? e.message : String(e);
      setError(msg);
      // draw error text
      if (canvas && ctx) {
        fitCanvasToDisplaySize();
        ctx.font = '12px system-ui, Arial';
        ctx.fillStyle = '#b91c1c';
        ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
        ctx.fillText('K线获取失败:' + msg, 8, 18);
      }
    }
  }

  async function updatePriceOnce() {
    try {
      const res = await fetchPriceWithFallback();
      const price = res.price;
      lastPrice = price;
      let prevClose = null;
      try {
        const cfg = INTERVAL_MAP[currentInterval];
        try {
          const kl = await fetchKlinesBinance(cfg.bin, 3);
          if (kl && kl.length >= 2) prevClose = kl[kl.length - 2].close;
        } catch (be) {
          try {
            const klh = await fetchKlinesHuobi(cfg.huobi, 3);
            if (klh && klh.length >= 2) prevClose = klh[klh.length - 2].close;
          } catch (huInner) {
            const days = currentInterval === '1m' ? 1 : (currentInterval === '1h' ? 7 : 90);
            const prices = await fetchMarketChartPricesCoinGecko(days);
            const agg = aggregatePricesToCandles(prices, cfg.seconds, 3);
            if (agg && agg.length >= 2) prevClose = agg[agg.length - 2].close;
          }
        }
      } catch (inner) {}
      setPriceUI(price, prevClose);
      setUpdateTimeUI(Date.now(), res.source);
    } catch (e) {
      setError('价格获取失败:' + (e && e.message ? e.message : String(e)));
    }
  }

  function startTimers() {
    stopTimers();
    updateKlinesAndDraw();
    updatePriceOnce();
    priceTimer = setInterval(updatePriceOnce, PRICE_REFRESH_MS);
    const cfg = INTERVAL_MAP[currentInterval];
    let kRefresh = 60000;
    if (currentInterval === '1m') kRefresh = 15000;
    if (currentInterval === '1h') kRefresh = 60000;
    if (currentInterval === '1d') kRefresh = 300000;
    klineTimer = setInterval(updateKlinesAndDraw, kRefresh);
  }
  function stopTimers() {
    if (priceTimer) { clearInterval(priceTimer); priceTimer = null; }
    if (klineTimer) { clearInterval(klineTimer); klineTimer = null; }
  }

  // ---------- UI helpers ----------
  function refreshCountButtonsUI() {
    document.querySelectorAll('.count-btn').forEach(b => {
      const val = Number(b.getAttribute('data-count'));
      b.style.fontWeight = val === selectedCandleCount ? '700' : '400';
    });
  }

  function setError(msg) {
    const el = document.getElementById('btc-error');
    if (!el) return;
    if (!msg) { el.style.display = 'none'; el.textContent = ''; }
    else { el.style.display = 'block'; el.textContent = '初始化/网络错误:' + msg; }
  }

  function setPriceUI(price, prevClose) {
    const priceEl = document.getElementById('btc-price');
    const pctEl = document.getElementById('btc-pct');
    if (!priceEl) return;
    if (price == null) { priceEl.textContent = '--'; if (pctEl) pctEl.style.display = 'none'; return; }
    lastPrice = price;
    priceEl.textContent = price >= 1 ? price.toLocaleString(undefined, { maximumFractionDigits: 2 }) : price.toPrecision(6);
    tooltip.textContent = (price >= 1 ? price.toLocaleString(undefined, { maximumFractionDigits: 8 }) : price.toPrecision(12));
    if (prevClose != null && pctEl) {
      const pct = ((price - prevClose) / prevClose) * 100;
      pctEl.style.display = 'inline-block';
      pctEl.textContent = `${pct >= 0 ? '▲' : '▼'}${Math.abs(pct).toFixed(2)}%`;
      pctEl.style.background = pct >= 0 ? 'rgba(16,185,129,0.12)' : 'rgba(239,68,68,0.12)';
      pctEl.style.color = pct >= 0 ? '#10B981' : '#ef4444';
      pctEl.style.border = pct >= 0 ? '1px solid rgba(16,185,129,0.12)' : '1px solid rgba(239,68,68,0.12)';
    } else if (pctEl) { pctEl.style.display = 'none'; }
  }
  function setUpdateTimeUI(ts, source) {
    const el = document.getElementById('btc-minimap');
    if (!el) return;
    const d = ts ? new Date(ts) : new Date();
    el.textContent = `数据来源:${source} · 更新时间:${d.toLocaleString()}`;
  }
  function setRangeUI(n) { const el = document.getElementById('btc-range'); if (el) el.textContent = `点数: ${n}`; }

  // ---------- Events binding ----------
  function bindEvents() {
    document.querySelectorAll('.btc-btn').forEach(btn => {
      btn.addEventListener('click', async () => {
        const iv = btn.getAttribute('data-interval');
        if (iv === currentInterval) return;
        currentInterval = iv;
        document.querySelectorAll('.btc-btn').forEach(b => b.style.fontWeight = b.getAttribute('data-interval') === currentInterval ? '700' : '400');
        stopTimers();
        await updateKlinesAndDraw();
        startTimers();
      });
    });

    document.querySelectorAll('.count-btn').forEach(btn => {
      btn.addEventListener('click', async () => {
        const cnt = Number(btn.getAttribute('data-count'));
        if (!cnt || cnt === selectedCandleCount) return;
        selectedCandleCount = cnt;
        try { await setStored(CANDLE_KEY, selectedCandleCount); } catch (e) {}
        refreshCountButtonsUI();
        stopTimers();
        await updateKlinesAndDraw();
        startTimers();
      });
    });

    const toggle = document.getElementById('btc-toggle');
    if (toggle) {
      toggle.textContent = container.classList.contains('collapsed') ? '+' : '−';
      toggle.addEventListener('click', () => {
        container.classList.toggle('collapsed');
        const collapsed = container.classList.contains('collapsed');
        toggle.textContent = collapsed ? '+' : '−';
        // persist (async, no await necessary)
        setStored(COLLAPSED_KEY, collapsed ? 1 : 0).catch(() => {});
      });
    }

    const header = document.getElementById('btc-header');
    if (header) header.addEventListener('dblclick', () => container.classList.toggle('dark'));

    // drag
    let dragging = false;
    let startX = 0, startY = 0, initLeft = 0, initBottom = 0;
    if (header) header.addEventListener('mousedown', (e) => {
      dragging = true;
      startX = e.clientX;
      startY = e.clientY;
      const rect = container.getBoundingClientRect();
      initLeft = rect.left;
      initBottom = window.innerHeight - rect.bottom;
      document.body.style.userSelect = 'none';
    });
    window.addEventListener('mousemove', (e) => {
      if (!dragging) return;
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      let newLeft = initLeft + dx;
      let newBottom = initBottom - dy;
      newLeft = Math.max(6, Math.min(window.innerWidth - container.offsetWidth - 6, newLeft));
      newBottom = Math.max(6, Math.min(window.innerHeight - container.offsetHeight - 6, newBottom));
      container.style.left = `${newLeft}px`;
      container.style.bottom = `${newBottom}px`;
    });
    window.addEventListener('mouseup', () => { dragging = false; document.body.style.userSelect = ''; });

    // tooltip only when price text truncated
    const priceEl = document.getElementById('btc-price');
    function isTruncated(el) { return el && el.scrollWidth > el.clientWidth + 1; }
    if (priceEl) {
      priceEl.addEventListener('mouseenter', (e) => {
        try {
          if (!isTruncated(priceEl)) return;
          const rect = priceEl.getBoundingClientRect();
          const isDark = container.classList.contains('dark');
          tooltip.className = isDark ? '' : 'light';
          tooltip.style.display = 'block';
          tooltip.textContent = tooltip.textContent || priceEl.textContent || '';
          tooltip.style.left = Math.max(6, Math.min(window.innerWidth - 12 - tooltip.offsetWidth, rect.left + (rect.width - tooltip.offsetWidth) / 2)) + 'px';
          tooltip.style.top = (rect.top - tooltip.offsetHeight - 8) + 'px';
        } catch (err) {}
      });
      priceEl.addEventListener('mousemove', (e) => {
        try {
          if (tooltip.style.display !== 'block') return;
          const rect = priceEl.getBoundingClientRect();
          tooltip.style.left = Math.max(6, Math.min(window.innerWidth - 12 - tooltip.offsetWidth, rect.left + (rect.width - tooltip.offsetWidth) / 2)) + 'px';
        } catch (err) {}
      });
      priceEl.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; });
      window.addEventListener('scroll', () => { tooltip.style.display = 'none'; }, true);
    }
  }

  // ---------- resize debounce ----------
  let __resizeTimer = null;
  function onResizeDebounced() {
    if (__resizeTimer) clearTimeout(__resizeTimer);
    __resizeTimer = setTimeout(() => {
      try {
        fitCanvasToDisplaySize();
        if (typeof updateKlinesAndDraw === 'function') updateKlinesAndDraw();
      } catch (e) { console.error('resize redraw error', e); }
    }, 60);
  }
  window.addEventListener('resize', onResizeDebounced);

  // ---------- init (now reads persisted settings first, then creates DOM) ----------
  (async function init() {
    try {
      // read persisted settings (GM preferred, fallback to localStorage)
      const savedCollapsed = await getStored(COLLAPSED_KEY, null);
      const savedCount = await getStored(CANDLE_KEY, null);
      try {
        if (savedCount !== null && !isNaN(Number(savedCount))) selectedCandleCount = Number(savedCount);
      } catch (e) {}
      // If COLLAPSED_KEY not present, we keep default true for compatibility with prior behavior,
      // but you can change the default to false here if you prefer expanded by default.
      const collapsedDefault = (savedCollapsed === null) ? true : (Number(savedCollapsed) === 1 || savedCollapsed === true || String(savedCollapsed) === '1');

      // build DOM AFTER reading storage so initial state matches persisted state (no flicker)
      container = document.createElement('div');
      container.id = 'btc-widget';
      if (collapsedDefault) container.classList.add('collapsed');

      container.innerHTML = `
        <div id="btc-header" title="拖拽移动 / 双击切换深色">
          <div class="left">
            <div id="btc-symbol">BTC / USDT</div>
            <div id="btc-price">--</div>
            <div id="btc-pct" style="display:none">--</div>
          </div>
          <div id="btc-controls">
            <button class="btc-btn" data-interval="1m">分</button>
            <button class="btc-btn" data-interval="1h">时</button>
            <button class="btc-btn" data-interval="1d">天</button>
            <button id="btc-toggle" title="折叠">+</button>
          </div>
        </div>
        <div id="btc-chart-wrap">
          <canvas id="btc-canvas" width="${WIDTH - 16}" height="140"></canvas>
          <div id="btc-minimap">数据来源:-- · 更新时间:--</div>
          <div id="btc-error" style="display:none"></div>
        </div>
        <div id="btc-footer">
          <div id="btc-range">点数: --</div>
          <div id="btc-counts" aria-label="点数选择">
            <button class="count-btn" data-count="50">50</button>
            <button class="count-btn" data-count="100">100</button>
            <button class="count-btn" data-count="200">200</button>
          </div>
          <div id="btc-note">刷新:自动</div>
        </div>
      `;
      document.body.appendChild(container);

      tooltip = document.createElement('div');
      tooltip.id = 'btc-price-tooltip';
      document.body.appendChild(tooltip);

      // assign canvas & ctx AFTER DOM created
      canvas = document.getElementById('btc-canvas');
      ctx = canvas ? canvas.getContext('2d') : null;

      // UI init
      document.querySelectorAll('.btc-btn').forEach(b => {
        b.style.fontWeight = b.getAttribute('data-interval') === currentInterval ? '700' : '400';
      });
      refreshCountButtonsUI();
      fitCanvasToDisplaySize();
      bindEvents();
      startTimers();

      try {
        const userProxy = await getStored(PROXY_KEY, '');
        if (!userProxy) {
          console.info('如遇数据获取失败,可在控制台执行:localStorage.setItem("btc_widget_proxy", "https://api.allorigins.win/raw?url=") 并刷新页面。');
        }
      } catch (e) {}
    } catch (e) {
      setError('初始化失败:' + (e && e.message ? e.message : String(e)));
      console.error('BTC widget init error', e);
    }
  })();

  window.addEventListener('beforeunload', () => stopTimers());

})();