/** 多周期 K 线 · SQLite 后端 + localStorage · 弹窗大图 Lightweight Charts */
const CHART_INTERVALS = ["5m", "15m", "30m", "1h", "4h", "1d", "1w"];
const INTERVAL_LIMITS = {
"5m": 1000,
"15m": 1000,
"30m": 1000,
"1h": 1000,
"4h": 1000,
"1d": 500,
"1w": 500,
};
const chartDataCache = new Map();
const chartQueue = [];
let chartQueueRunning = false;
const CHART_FETCH_GAP_MS = 120;
const LS_KLINE_PREFIX = "ba_kline_";
const KLINE_TTL_MS = 60 * 60 * 1000;
const COLORS = {
bg: "#0d1118",
grid: "#2a3548",
up: "#0ecb81",
down: "#f6465d",
volUp: "rgba(14, 203, 129, 0.55)",
volDown: "rgba(246, 70, 93, 0.55)",
text: "#8b9cb3",
};
const MINI_SIZE = { w: 380, h: 100 };
/** 弹窗 K 线区域固定尺寸(带鱼屏居中大图) */
const MODAL_CHART_SIZE = { w: 1920, h: 1080 };
/** 自动模式下右侧留空的 K 线根数 */
const CHART_RIGHT_OFFSET_BARS = 10;
const DEFAULT_MINI_INTERVAL = "1d";
let chartModalSymbol = "";
let chartModalInterval = "1d";
let lwcChart = null;
let lwcCandleSeries = null;
let lwcVolumeSeries = null;
let lwcResizeObserver = null;
let lwcPriceLines = [];
const symbolPriceMeta = new Map();
let lwcModalCandles = [];
let lwcModalInterval = "1d";
let lwcModalPriceMeta = { tick_size: "0.01", price_precision: 2 };
let lwcOnVisibleRangeChange = null;
let lwcOnCrosshairMove = null;
let lwcOnChartClick = null;
let lwcPinnedCandleTime = null;
const BINANCE_KLINE_WS = "wss://fstream.binance.com/ws";
let klineWs = null;
let klineWsSymbol = "";
let klineWsInterval = "";
let klineWsReconnectTimer = null;
let modalMetaBase = { count: 0, source: "db", interval: "1d" };
let highLowWsRaf = 0;
function cacheKey(symbol, interval) {
return `${symbol}:${interval}`;
}
function limitForInterval(interval) {
return INTERVAL_LIMITS[interval] || 500;
}
function modalChartSize() {
return {
w: Math.min(MODAL_CHART_SIZE.w, window.innerWidth - 32),
h: Math.min(MODAL_CHART_SIZE.h, window.innerHeight - 32),
};
}
function loadKlineFromLS(symbol, interval) {
try {
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol + "_" + interval);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null;
return obj;
} catch {
return null;
}
}
function saveKlineToLS(symbol, interval, candles, source, priceMeta) {
try {
localStorage.setItem(
LS_KLINE_PREFIX + symbol + "_" + interval,
JSON.stringify({
ts: Date.now(),
candles,
source,
interval,
tick_size: priceMeta?.tick_size,
price_precision: priceMeta?.price_precision,
})
);
} catch {
/* quota */
}
}
function sourceLabel(source) {
if (source === "ws") return "实时";
if (source === "browser") return "浏览器";
if (source === "db") return "本地";
if (source === "db_stale") return "本地(旧)";
if (source === "memory") return "缓存";
return "同步";
}
function parseMinMove(tickSize) {
const n = Number(tickSize);
return Number.isFinite(n) && n > 0 ? n : 0.01;
}
function formatPrice(price, precision) {
return Number(price).toFixed(precision);
}
function formatVolume(val) {
const v = Number(val);
if (!Number.isFinite(v)) return "—";
if (v >= 1e8) return `${(v / 1e8).toFixed(2)}亿`;
if (v >= 1e4) return `${(v / 1e4).toFixed(2)}万`;
return v.toFixed(2);
}
function calcAmplitude(candle) {
const open = Number(candle.open);
const high = Number(candle.high);
const low = Number(candle.low);
if (!open) return "—";
return `${(((high - low) / open) * 100).toFixed(2)}%`;
}
function lwcTimeEquals(a, b) {
if (a == null || b == null) return false;
if (typeof a === "number" && typeof b === "number") return a === b;
if (typeof a === "object" && typeof b === "object") {
return a.year === b.year && a.month === b.month && a.day === b.day;
}
return false;
}
function findCandleByLwcTime(time) {
if (time == null) return null;
for (const c of lwcModalCandles) {
if (lwcTimeEquals(toLwcTime(c.time, lwcModalInterval), time)) return c;
}
return null;
}
function formatCandleTimeLabel(ms, interval) {
const d = new Date(ms);
if (interval === "1d" || interval === "1w") {
return d.toLocaleDateString("zh-CN");
}
return d.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function renderOhlcPanel(candle, modeLabel) {
const panel = document.getElementById("chart-ohlc-panel");
if (!panel) return;
if (!candle) {
panel.innerHTML = '—';
return;
}
const precision = lwcModalPriceMeta.price_precision ?? 2;
const up = Number(candle.close) >= Number(candle.open);
const closeCls = up ? "up" : "down";
const vol = formatVolume(candle.quote_volume || candle.volume);
const timeLabel = formatCandleTimeLabel(candle.time, lwcModalInterval);
panel.innerHTML = `
${modeLabel}
${timeLabel}
开 ${formatPrice(candle.open, precision)}
高 ${formatPrice(candle.high, precision)}
低 ${formatPrice(candle.low, precision)}
收 ${formatPrice(candle.close, precision)}
量 ${vol}
振幅 ${calcAmplitude(candle)}
`;
}
function showLatestOhlcPanel() {
const last = lwcModalCandles[lwcModalCandles.length - 1];
renderOhlcPanel(last, "最新");
}
function updateOhlcFromTime(time, modeLabel) {
const candle = findCandleByLwcTime(time);
if (candle) renderOhlcPanel(candle, modeLabel);
}
function applyChartRightOffset() {
if (!lwcChart) return;
lwcChart.timeScale().applyOptions({ rightOffset: CHART_RIGHT_OFFSET_BARS });
}
function fitChartContent() {
if (!lwcChart) return;
applyChartRightOffset();
lwcChart.timeScale().fitContent();
}
function onChartAutoscale() {
if (!lwcChart) return;
lwcPinnedCandleTime = null;
fitChartContent();
lwcChart.priceScale("right").applyOptions({ autoScale: true });
requestAnimationFrame(() => {
updateHighLowForVisibleWindow();
showLatestOhlcPanel();
});
}
function unbindChartInteractions() {
if (lwcChart && lwcOnCrosshairMove) {
lwcChart.unsubscribeCrosshairMove(lwcOnCrosshairMove);
}
if (lwcChart && lwcOnChartClick) {
lwcChart.unsubscribeClick(lwcOnChartClick);
}
lwcOnCrosshairMove = null;
lwcOnChartClick = null;
}
function bindChartInteractions() {
if (!lwcChart) return;
unbindChartInteractions();
lwcOnCrosshairMove = (param) => {
if (lwcPinnedCandleTime != null) return;
if (param.time) {
updateOhlcFromTime(param.time, "当前");
return;
}
showLatestOhlcPanel();
};
lwcOnChartClick = (param) => {
if (!param.time) return;
if (lwcPinnedCandleTime != null && lwcTimeEquals(lwcPinnedCandleTime, param.time)) {
lwcPinnedCandleTime = null;
updateOhlcFromTime(param.time, "当前");
return;
}
lwcPinnedCandleTime = param.time;
updateOhlcFromTime(param.time, "选中");
};
lwcChart.subscribeCrosshairMove(lwcOnCrosshairMove);
lwcChart.subscribeClick(lwcOnChartClick);
}
function rememberPriceMeta(symbol, meta) {
if (!meta?.tick_size) return null;
const priceMeta = {
tick_size: meta.tick_size,
price_precision: Number(meta.price_precision ?? 2),
};
symbolPriceMeta.set(symbol, priceMeta);
return priceMeta;
}
function getPriceMeta(symbol, fallback) {
return (
symbolPriceMeta.get(symbol) ||
(fallback?.tick_size ? rememberPriceMeta(symbol, fallback) : null) || {
tick_size: "0.01",
price_precision: 2,
}
);
}
function findCandleExtremes(candles, interval) {
let maxHigh = -Infinity;
let minLow = Infinity;
let highTime = null;
let lowTime = null;
for (const c of candles) {
if (c.high > maxHigh) {
maxHigh = c.high;
highTime = toLwcTime(c.time, interval);
}
if (c.low < minLow) {
minLow = c.low;
lowTime = toLwcTime(c.time, interval);
}
}
return { maxHigh, minLow, highTime, lowTime };
}
function candlesInLogicalRange(candles, range) {
if (!range || !candles.length) return candles;
const from = Math.max(0, Math.floor(range.from));
const to = Math.min(candles.length - 1, Math.ceil(range.to));
if (from > to) return [];
return candles.slice(from, to + 1);
}
function updateHighLowForVisibleWindow() {
if (!lwcChart || !lwcCandleSeries || !lwcModalCandles.length) return;
const range = lwcChart.timeScale().getVisibleLogicalRange();
const visible = candlesInLogicalRange(lwcModalCandles, range);
const subset = visible.length ? visible : lwcModalCandles;
applyHighLowAnnotations(subset, lwcModalInterval, lwcModalPriceMeta);
}
function bindVisibleRangeHighLow() {
if (!lwcChart) return;
unbindVisibleRangeHighLow();
lwcOnVisibleRangeChange = () => {
updateHighLowForVisibleWindow();
};
lwcChart.timeScale().subscribeVisibleLogicalRangeChange(lwcOnVisibleRangeChange);
}
function unbindVisibleRangeHighLow() {
if (lwcChart && lwcOnVisibleRangeChange) {
lwcChart.timeScale().unsubscribeVisibleLogicalRangeChange(lwcOnVisibleRangeChange);
}
lwcOnVisibleRangeChange = null;
}
function toLwcTime(ms, interval) {
if (interval === "1d" || interval === "1w") {
const d = new Date(ms);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
};
}
return Math.floor(ms / 1000);
}
function candlesToLwc(candles, interval) {
const ohlc = [];
const vol = [];
for (const c of candles) {
const t = toLwcTime(c.time, interval);
const up = c.close >= c.open;
ohlc.push({
time: t,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
});
vol.push({
time: t,
value: Number(c.quote_volume || c.volume || 0),
color: up ? COLORS.volUp : COLORS.volDown,
});
}
return { ohlc, vol };
}
function upsertModalCandleFromWs(k) {
const candle = {
time: Number(k.t),
open: Number(k.o),
high: Number(k.h),
low: Number(k.l),
close: Number(k.c),
volume: Number(k.v),
quote_volume: Number(k.q),
};
const idx = lwcModalCandles.findIndex((c) => c.time === candle.time);
if (idx >= 0) {
lwcModalCandles[idx] = candle;
} else if (
!lwcModalCandles.length ||
candle.time > lwcModalCandles[lwcModalCandles.length - 1].time
) {
lwcModalCandles.push(candle);
const max = limitForInterval(lwcModalInterval);
while (lwcModalCandles.length > max) lwcModalCandles.shift();
}
return candle;
}
function applyRealtimeCandle(candle) {
if (!lwcCandleSeries || !lwcVolumeSeries) return;
const t = toLwcTime(candle.time, lwcModalInterval);
const up = candle.close >= candle.open;
lwcCandleSeries.update({
time: t,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
});
lwcVolumeSeries.update({
time: t,
value: Number(candle.quote_volume || candle.volume || 0),
color: up ? COLORS.volUp : COLORS.volDown,
});
}
function syncChartCacheFromModal() {
const key = cacheKey(chartModalSymbol, lwcModalInterval);
const cached = chartDataCache.get(key);
if (cached) {
cached.candles = lwcModalCandles.slice();
cached.source = "ws";
}
}
function scheduleHighLowFromWs() {
if (highLowWsRaf) return;
highLowWsRaf = requestAnimationFrame(() => {
highLowWsRaf = 0;
updateHighLowForVisibleWindow();
});
}
function onWsKlineUpdate(k) {
const candle = upsertModalCandleFromWs(k);
applyRealtimeCandle(candle);
syncChartCacheFromModal();
if (lwcPinnedCandleTime == null) {
renderOhlcPanel(candle, "实时");
}
scheduleHighLowFromWs();
}
function isKlineWsLive() {
return klineWs?.readyState === WebSocket.OPEN;
}
function updateModalHint() {
const hint = document.getElementById("chart-modal-hint");
if (!hint) return;
const src = isKlineWsLive() ? "实时 WS" : sourceLabel(modalMetaBase.source);
hint.textContent = `${modalMetaBase.count} 根 · ${src} · 十字线看当前 · 点击选中 · Esc 关闭`;
}
function disconnectKlineWs() {
if (klineWsReconnectTimer) {
clearTimeout(klineWsReconnectTimer);
klineWsReconnectTimer = null;
}
if (klineWs) {
klineWs.onopen = null;
klineWs.onmessage = null;
klineWs.onclose = null;
klineWs.onerror = null;
try {
klineWs.close();
} catch {
/* ignore */
}
klineWs = null;
}
klineWsSymbol = "";
klineWsInterval = "";
}
function scheduleKlineWsReconnect() {
if (!chartModalSymbol || klineWsReconnectTimer) return;
klineWsReconnectTimer = setTimeout(() => {
klineWsReconnectTimer = null;
if (chartModalSymbol) connectKlineWs(chartModalSymbol, chartModalInterval);
}, 3000);
}
function connectKlineWs(symbol, interval) {
disconnectKlineWs();
const sym = symbol.toUpperCase();
const iv = interval.toLowerCase();
klineWsSymbol = sym;
klineWsInterval = iv;
const ws = new WebSocket(`${BINANCE_KLINE_WS}/${sym.toLowerCase()}@kline_${iv}`);
klineWs = ws;
ws.onopen = () => updateModalHint();
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.e !== "kline" || !msg.k) return;
if (msg.k.s !== sym || msg.k.i !== iv) return;
onWsKlineUpdate(msg.k);
} catch {
/* ignore malformed */
}
};
ws.onclose = () => {
if (klineWs === ws) {
klineWs = null;
updateModalHint();
if (chartModalSymbol && klineWsSymbol === sym && klineWsInterval === iv) {
scheduleKlineWsReconnect();
}
}
};
ws.onerror = () => updateModalHint();
}
function enqueueCharts(root) {
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
const symbol = box.dataset.symbol;
if (!symbol || box.dataset.loaded === "1" || box.dataset.loading === "1") return;
chartQueue.push(box);
});
runChartQueue();
}
async function runChartQueue() {
if (chartQueueRunning) return;
chartQueueRunning = true;
while (chartQueue.length) {
const box = chartQueue.shift();
if (!box || !box.isConnected) continue;
await loadMiniChart(box);
await sleep(CHART_FETCH_GAP_MS);
}
chartQueueRunning = false;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function volOf(c) {
return Number(c.quote_volume || c.volume || 0);
}
function setupCanvas(canvas, displayW, displayH) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.style.width = `${displayW}px`;
canvas.style.height = `${displayH}px`;
const pw = Math.floor(displayW * dpr);
const ph = Math.floor(displayH * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, w: displayW, h: displayH };
}
function drawCandlestickChart(canvas, candles, options = {}) {
if (!canvas || !candles.length) return;
const large = options.large === true;
const size = large ? modalChartSize() : MINI_SIZE;
const volRatio = large ? 0.22 : 0.32;
const pad = large
? { t: 16, r: 16, b: 28, l: 56 }
: { t: 6, r: 6, b: 14, l: 6 };
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
const priceH = (h - pad.t - pad.b) * (1 - volRatio);
const volH = (h - pad.t - pad.b) * volRatio;
const volTop = pad.t + priceH + (large ? 8 : 4);
const plotW = w - pad.l - pad.r;
const n = candles.length;
const step = plotW / n;
let pMin = Infinity;
let pMax = -Infinity;
let vMax = 0;
for (const c of candles) {
pMin = Math.min(pMin, c.low);
pMax = Math.max(pMax, c.high);
vMax = Math.max(vMax, volOf(c));
}
const pRange = pMax - pMin || 1;
vMax = vMax || 1;
const yPrice = (p) => pad.t + priceH * (1 - (p - pMin) / pRange);
const yVol = (v) => volTop + volH * (1 - v / vMax);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.l, volTop - 2);
ctx.lineTo(w - pad.r, volTop - 2);
ctx.stroke();
const bodyW = Math.max(large ? 2 : 1, step * (large ? 0.72 : 0.68));
for (let i = 0; i < n; i++) {
const c = candles[i];
const up = c.close >= c.open;
const x = pad.l + i * step + step / 2;
const color = up ? COLORS.up : COLORS.down;
const volColor = up ? COLORS.volUp : COLORS.volDown;
const yHigh = yPrice(c.high);
const yLow = yPrice(c.low);
const yOpen = yPrice(c.open);
const yClose = yPrice(c.close);
ctx.strokeStyle = color;
ctx.lineWidth = large ? 1.5 : 1;
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
const top = Math.min(yOpen, yClose);
const bodyHeight = Math.max(large ? 2 : 1, Math.abs(yClose - yOpen));
ctx.fillStyle = color;
ctx.fillRect(x - bodyW / 2, top, bodyW, bodyHeight);
const v = volOf(c);
const barH = volH * (v / vMax);
if (barH > 0.5) {
ctx.fillStyle = volColor;
ctx.fillRect(x - bodyW / 2, yVol(v), bodyW, barH);
}
}
}
function drawEmptyChart(canvas) {
if (!canvas) return;
const { ctx, w, h } = setupCanvas(canvas, MINI_SIZE.w, MINI_SIZE.h);
ctx.fillStyle = "#1a2332";
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = COLORS.text;
ctx.font = "13px sans-serif";
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
}
async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL, forceRefresh = false) {
const key = cacheKey(symbol, interval);
if (!forceRefresh) {
const cached = chartDataCache.get(key);
if (cached) return cached;
const ls = loadKlineFromLS(symbol, interval);
if (ls) {
const priceMeta = rememberPriceMeta(symbol, ls);
const result = {
candles: ls.candles,
source: ls.source || "browser",
interval,
tick_size: priceMeta?.tick_size,
price_precision: priceMeta?.price_precision,
};
chartDataCache.set(key, result);
return result;
}
}
const limit = limitForInterval(interval);
const refreshQ = forceRefresh ? "&refresh=true" : "";
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}${refreshQ}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
const priceMeta = rememberPriceMeta(symbol, data);
const result = {
candles: data.candles || [],
source: data.source || "db",
interval,
tick_size: priceMeta.tick_size,
price_precision: priceMeta.price_precision,
};
chartDataCache.set(key, result);
saveKlineToLS(symbol, interval, result.candles, result.source, priceMeta);
return result;
}
async function loadMiniChart(box) {
const symbol = box.dataset.symbol;
if (!symbol) return;
box.dataset.loading = "1";
const canvas = box.querySelector("canvas");
const status = box.querySelector(".chart-status");
if (status) status.textContent = "加载…";
try {
const { candles, source } = await fetchKlines(symbol, DEFAULT_MINI_INTERVAL);
if (!candles.length) throw new Error("无K线数据");
drawCandlestickChart(canvas, candles, { large: false });
box.dataset.loaded = "1";
if (status) status.textContent = `${candles.length}日·${sourceLabel(source)}`;
box.title = `${symbol} 日K ${candles.length}根 (${sourceLabel(source)}),点击查看大图`;
} catch (e) {
if (status) status.textContent = "—";
box.title = `${symbol}: ${e.message}`;
drawEmptyChart(canvas);
} finally {
box.dataset.loading = "0";
}
}
function destroyLwcChart() {
disconnectKlineWs();
unbindVisibleRangeHighLow();
unbindChartInteractions();
clearHighLowAnnotations();
lwcModalCandles = [];
lwcPinnedCandleTime = null;
if (lwcResizeObserver) {
lwcResizeObserver.disconnect();
lwcResizeObserver = null;
}
if (lwcChart) {
lwcChart.remove();
lwcChart = null;
lwcCandleSeries = null;
lwcVolumeSeries = null;
}
}
function clearHighLowAnnotations() {
if (lwcCandleSeries) {
lwcPriceLines.forEach((line) => {
try {
lwcCandleSeries.removePriceLine(line);
} catch {
/* already removed */
}
});
lwcCandleSeries.setMarkers([]);
}
lwcPriceLines = [];
}
function applySeriesPriceFormat(priceMeta) {
if (!lwcCandleSeries) return;
const precision = priceMeta.price_precision;
const minMove = parseMinMove(priceMeta.tick_size);
lwcCandleSeries.applyOptions({
priceFormat: {
type: "price",
precision,
minMove,
},
});
}
function applyHighLowAnnotations(candles, interval, priceMeta) {
if (!lwcCandleSeries || !candles.length) return;
clearHighLowAnnotations();
const { maxHigh, minLow, highTime, lowTime } = findCandleExtremes(candles, interval);
if (!Number.isFinite(maxHigh) || !Number.isFinite(minLow)) return;
const precision = priceMeta.price_precision;
const highText = formatPrice(maxHigh, precision);
const lowText = formatPrice(minLow, precision);
lwcPriceLines.push(
lwcCandleSeries.createPriceLine({
price: maxHigh,
color: COLORS.up,
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
axisLabelVisible: true,
title: `最高 ${highText}`,
}),
lwcCandleSeries.createPriceLine({
price: minLow,
color: COLORS.down,
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
axisLabelVisible: true,
title: `最低 ${lowText}`,
})
);
const markers = [];
if (highTime != null) {
markers.push({
time: highTime,
position: "aboveBar",
color: COLORS.up,
shape: "circle",
text: `高 ${highText}`,
});
}
if (lowTime != null) {
markers.push({
time: lowTime,
position: "belowBar",
color: COLORS.down,
shape: "circle",
text: `低 ${lowText}`,
});
}
lwcCandleSeries.setMarkers(markers);
}
function ensureLwcChart(container) {
if (typeof LightweightCharts === "undefined") {
container.innerHTML = '图表库加载失败
';
return null;
}
destroyLwcChart();
const { w, h } = modalChartSize();
container.style.width = `${w}px`;
container.style.height = `${h}px`;
lwcChart = LightweightCharts.createChart(container, {
width: w,
height: h,
layout: {
background: { color: COLORS.bg },
textColor: COLORS.text,
},
grid: {
vertLines: { visible: false },
horzLines: { visible: false },
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
rightPriceScale: { borderColor: COLORS.grid },
timeScale: {
borderColor: COLORS.grid,
timeVisible: true,
secondsVisible: false,
rightOffset: CHART_RIGHT_OFFSET_BARS,
},
});
lwcCandleSeries = lwcChart.addCandlestickSeries({
upColor: COLORS.up,
downColor: COLORS.down,
borderUpColor: COLORS.up,
borderDownColor: COLORS.down,
wickUpColor: COLORS.up,
wickDownColor: COLORS.down,
});
lwcVolumeSeries = lwcChart.addHistogramSeries({
priceFormat: { type: "volume" },
priceScaleId: "",
});
lwcVolumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
lwcResizeObserver = new ResizeObserver(() => {
if (!lwcChart || !container.isConnected) return;
const rect = container.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
lwcChart.applyOptions({ width: rect.width, height: rect.height });
}
});
lwcResizeObserver.observe(container);
bindVisibleRangeHighLow();
bindChartInteractions();
return lwcChart;
}
function renderLwcChart(candles, interval, priceMeta) {
const container = document.getElementById("chart-modal-container");
if (!container) return;
if (!lwcChart) ensureLwcChart(container);
if (!lwcCandleSeries || !lwcVolumeSeries) return;
const meta = getPriceMeta(chartModalSymbol, priceMeta);
applySeriesPriceFormat(meta);
lwcModalCandles = candles;
lwcModalInterval = interval;
lwcModalPriceMeta = meta;
lwcPinnedCandleTime = null;
const { ohlc, vol } = candlesToLwc(candles, interval);
lwcCandleSeries.setData(ohlc);
lwcVolumeSeries.setData(vol);
fitChartContent();
requestAnimationFrame(() => {
updateHighLowForVisibleWindow();
showLatestOhlcPanel();
});
}
function updateIntervalTabs() {
document.querySelectorAll(".chart-interval-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.interval === chartModalInterval);
});
}
function updateModalMeta(candles, source, interval) {
modalMetaBase = { count: candles.length, source, interval };
const title = document.getElementById("chart-modal-title");
if (title) {
title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`;
}
updateModalHint();
}
async function loadModalChart(interval) {
chartModalInterval = interval;
updateIntervalTabs();
const container = document.getElementById("chart-modal-container");
const hint = document.getElementById("chart-modal-hint");
if (hint) hint.textContent = "加载中…";
try {
const { candles, source, tick_size, price_precision } = await fetchKlines(
chartModalSymbol,
interval,
true
);
if (!candles.length) throw new Error("无K线数据");
renderLwcChart(candles, interval, { tick_size, price_precision });
updateModalMeta(candles, source, interval);
connectKlineWs(chartModalSymbol, interval);
} catch (e) {
disconnectKlineWs();
if (hint) hint.textContent = `加载失败: ${e.message}`;
destroyLwcChart();
if (container) {
container.innerHTML = `${e.message}
`;
}
}
}
function closeChartModal() {
const modal = document.getElementById("chart-modal");
if (!modal) return;
modal.classList.add("hidden");
destroyLwcChart();
chartModalSymbol = "";
}
async function openChartModal(symbol) {
const key = cacheKey(symbol, DEFAULT_MINI_INTERVAL);
const cached = chartDataCache.get(key);
if (!cached?.candles?.length) {
try {
await fetchKlines(symbol, DEFAULT_MINI_INTERVAL);
} catch {
return;
}
}
chartModalSymbol = symbol;
chartModalInterval = DEFAULT_MINI_INTERVAL;
const modal = document.getElementById("chart-modal");
modal.classList.remove("hidden");
const container = document.getElementById("chart-modal-container");
if (container) container.innerHTML = "";
await loadModalChart(DEFAULT_MINI_INTERVAL);
}
function setupChartModal() {
let modal = document.getElementById("chart-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "chart-modal";
modal.className = "chart-modal hidden";
modal.innerHTML = `
`;
document.body.appendChild(modal);
const tabs = modal.querySelector("#chart-interval-tabs");
CHART_INTERVALS.forEach((iv) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chart-interval-btn";
btn.dataset.interval = iv;
btn.textContent = iv;
btn.addEventListener("click", () => {
if (iv === chartModalInterval || !chartModalSymbol) return;
loadModalChart(iv);
});
tabs.appendChild(btn);
});
modal.querySelector(".chart-modal-close").onclick = closeChartModal;
document.getElementById("chart-autoscale-btn")?.addEventListener("click", onChartAutoscale);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeChartModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeChartModal();
});
window.addEventListener("resize", () => {
if (!chartModalSymbol || !lwcChart) return;
const container = document.getElementById("chart-modal-container");
if (!container?.isConnected) return;
const { w, h } = modalChartSize();
container.style.width = `${w}px`;
container.style.height = `${h}px`;
lwcChart.applyOptions({ width: w, height: h });
});
}
document.body.addEventListener("click", (e) => {
const box = e.target.closest(".mini-chart[data-symbol]");
if (!box || box.dataset.loaded !== "1") return;
openChartModal(box.dataset.symbol);
});
}
setupChartModal();