Files
2026-05-30 11:12:47 +08:00

1044 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 多周期 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 = '<span class="chart-ohlc-empty">—</span>';
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 = `
<div class="chart-ohlc-head">
<span class="chart-ohlc-mode">${modeLabel}</span>
<span class="chart-ohlc-time">${timeLabel}</span>
</div>
<div class="chart-ohlc-row">
<span>开 <b>${formatPrice(candle.open, precision)}</b></span>
<span>高 <b class="up">${formatPrice(candle.high, precision)}</b></span>
<span>低 <b class="down">${formatPrice(candle.low, precision)}</b></span>
<span>收 <b class="${closeCls}">${formatPrice(candle.close, precision)}</b></span>
<span>量 <b>${vol}</b></span>
<span>振幅 <b>${calcAmplitude(candle)}</b></span>
</div>`;
}
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 = '<p class="chart-lwc-fallback">图表库加载失败</p>';
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 = `<p class="chart-lwc-fallback">${e.message}</p>`;
}
}
}
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 = `
<div class="chart-modal-inner">
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
<div class="chart-modal-head">
<h3 id="chart-modal-title"></h3>
<div class="chart-interval-tabs" id="chart-interval-tabs"></div>
</div>
<p class="chart-modal-hint" id="chart-modal-hint"></p>
<div class="chart-modal-canvas-wrap">
<div id="chart-ohlc-panel" class="chart-ohlc-panel"></div>
<button type="button" id="chart-autoscale-btn" class="chart-autoscale-btn">自动</button>
<div id="chart-modal-container" class="chart-lwc-container"></div>
</div>
</div>`;
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();