alphax/static/chart_widgets.js
2026-05-30 23:23:31 +08:00

707 lines
26 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.

window.AlphaXCharts = (function () {
var cssInjected = false;
function injectCss() {
if (cssInjected) return;
cssInjected = true;
var style = document.createElement("style");
style.textContent = [
".ax-chart{position:relative;width:100%;height:100%;min-height:220px;outline:none;touch-action:pan-y}",
".ax-chart canvas{display:block;width:100%;height:100%}",
".ax-tooltip{position:absolute;z-index:4;min-width:172px;max-width:240px;padding:10px 11px;border:1px solid rgba(148,163,184,.32);border-radius:14px;background:rgba(255,255,255,.94);box-shadow:0 18px 42px rgba(15,23,42,.16);backdrop-filter:blur(10px);font-size:12px;color:var(--slate,#4b5563);pointer-events:none;transform:translate(-50%,-112%);opacity:0;transition:opacity .12s ease}",
".ax-tooltip.show{opacity:1}",
".ax-tip-date{font-weight:950;color:var(--ink,#111827);margin-bottom:6px}",
".ax-tip-row{display:flex;justify-content:space-between;gap:14px;line-height:1.55}",
".ax-tip-row b{color:var(--ink,#111827)}",
".ax-tip-row .pos{color:var(--green,#00b473)}",
".ax-tip-row .neg{color:var(--red,#e53e3e)}",
".ax-hint{position:absolute;right:10px;bottom:8px;border:1px solid rgba(148,163,184,.24);background:rgba(255,255,255,.72);border-radius:999px;padding:4px 8px;color:var(--stone,#8e91a0);font-size:10px;font-weight:850;pointer-events:none}"
].join("");
document.head.appendChild(style);
}
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
return {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c];
});
}
function fmt(v, d) {
v = Number(v || 0);
return v.toLocaleString(undefined, {maximumFractionDigits: d == null ? 2 : d, minimumFractionDigits: 0});
}
function color(name, fallback) {
var value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function cls(v) {
v = Number(v || 0);
return v > 0 ? "pos" : v < 0 ? "neg" : "";
}
function renderEquity(container, payload) {
injectCss();
var points = (payload && payload.points) || [];
if (!container) return;
if (container._axDestroy) {
container._axDestroy();
container._axDestroy = null;
}
if (!points.length) {
container.innerHTML = '<div class="empty">暂无收益曲线数据</div>';
return;
}
container.innerHTML = '<div class="ax-chart" tabindex="0" aria-label="可交互权益曲线,移动鼠标查看每日明细,点击可锁定"><canvas></canvas><div class="ax-tooltip" aria-hidden="true"></div><div class="ax-hint">hover 查看明细</div></div>';
var root = container.querySelector(".ax-chart");
var canvas = root.querySelector("canvas");
var ctx = canvas.getContext("2d");
var tip = root.querySelector(".ax-tooltip");
var locked = false;
var activeIdx = points.length - 1;
var ro = null;
var equities = points.map(function (p) { return Number(p.equity_usdt || 0); });
var pnls = points.map(function (p) { return Number(p.daily_pnl_usdt || 0); });
var minEq = Math.min.apply(null, equities);
var maxEq = Math.max.apply(null, equities);
if (maxEq === minEq) { maxEq += 1; minEq -= 1; }
var maxAbs = Math.max.apply(null, [1].concat(pnls.map(function (v) { return Math.abs(v); })));
function metrics() {
var rect = root.getBoundingClientRect();
var w = Math.max(320, rect.width || container.clientWidth || 960);
var h = Math.max(220, rect.height || container.clientHeight || 260);
var padL = 4, padR = 4, top = 22, barBase = h - 36, lineH = Math.max(96, barBase - top - 52);
var chartW = w - padL - padR;
return {w: w, h: h, padL: padL, padR: padR, top: top, barBase: barBase, lineH: lineH, chartW: chartW};
}
function x(i, m) {
return m.padL + (points.length === 1 ? m.chartW / 2 : i * m.chartW / (points.length - 1));
}
function y(v, m) {
return m.top + (maxEq - v) * m.lineH / (maxEq - minEq);
}
function resizeCanvas(m) {
var dpr = Math.max(1, window.devicePixelRatio || 1);
var bw = Math.round(m.w * dpr);
var bh = Math.round(m.h * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = m.w + "px";
canvas.style.height = m.h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function drawGrid(m) {
ctx.strokeStyle = "rgba(148,163,184,.25)";
ctx.lineWidth = 1;
[m.top, m.top + m.lineH / 2, m.top + m.lineH].forEach(function (gy) {
ctx.beginPath();
ctx.moveTo(m.padL, gy);
ctx.lineTo(m.w - m.padR, gy);
ctx.stroke();
});
}
function drawBars(m) {
var barW = Math.max(3, m.chartW / points.length * .55);
points.forEach(function (p, i) {
var v = Number(p.daily_pnl_usdt || 0);
var bh = Math.abs(v) / maxAbs * 44;
var bx = x(i, m) - barW / 2;
var by = v >= 0 ? m.barBase - bh : m.barBase;
ctx.fillStyle = v >= 0 ? "rgba(0,180,115,.45)" : "rgba(229,62,62,.42)";
ctx.fillRect(bx, by, barW, Math.max(1, bh));
});
}
function drawEquity(m) {
var blue = color("--blue", "#4262ff");
var area = ctx.createLinearGradient(0, m.top, 0, m.barBase);
area.addColorStop(0, "rgba(66,98,255,.16)");
area.addColorStop(1, "rgba(66,98,255,.015)");
ctx.beginPath();
ctx.moveTo(x(0, m), m.barBase);
points.forEach(function (p, i) { ctx.lineTo(x(i, m), y(Number(p.equity_usdt || 0), m)); });
ctx.lineTo(x(points.length - 1, m), m.barBase);
ctx.closePath();
ctx.fillStyle = area;
ctx.fill();
ctx.beginPath();
points.forEach(function (p, i) {
var px = x(i, m), py = y(Number(p.equity_usdt || 0), m);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.strokeStyle = blue;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.stroke();
}
function drawLabels(m) {
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.font = "800 11px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(String(points[0].date || ""), m.padL + 6, m.h - 14);
ctx.fillText("权益 " + fmt(minEq, 0) + "U - " + fmt(maxEq, 0) + "U", m.padL + 6, 14);
ctx.textAlign = "right";
ctx.fillText(String(points[points.length - 1].date || ""), m.w - m.padR - 6, m.h - 14);
ctx.fillText("点击锁定 · Esc 取消", m.w - m.padR - 6, 14);
}
function drawFocus(m) {
if (activeIdx == null) return;
var p = points[activeIdx];
var px = x(activeIdx, m);
var py = y(Number(p.equity_usdt || 0), m);
ctx.save();
ctx.strokeStyle = "rgba(15,23,42,.38)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(px, m.top);
ctx.lineTo(px, m.barBase);
ctx.moveTo(m.padL, py);
ctx.lineTo(m.w - m.padR, py);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(px, py, 5, 0, Math.PI * 2);
ctx.fillStyle = color("--canvas", "#fff");
ctx.fill();
ctx.strokeStyle = color("--blue", "#4262ff");
ctx.lineWidth = 3;
ctx.stroke();
placeTip(p, px, py, m);
}
function render() {
var m = metrics();
resizeCanvas(m);
ctx.clearRect(0, 0, m.w, m.h);
drawGrid(m);
drawBars(m);
drawEquity(m);
drawFocus(m);
drawLabels(m);
}
function nearestFromEvent(ev) {
var rect = canvas.getBoundingClientRect();
var sx = ev.clientX - rect.left;
var m = metrics();
var idx = Math.round((sx - m.padL) / Math.max(1, m.chartW) * (points.length - 1));
return Math.max(0, Math.min(points.length - 1, idx));
}
function placeTip(p, px, py, m) {
tip.innerHTML =
'<div class="ax-tip-date">' + esc(p.date || "--") + '</div>' +
'<div class="ax-tip-row"><span>账户权益</span><b>' + fmt(p.equity_usdt, 2) + 'U</b></div>' +
'<div class="ax-tip-row"><span>每日收益</span><b class="' + cls(p.daily_pnl_usdt) + '">' + (Number(p.daily_pnl_usdt || 0) > 0 ? "+" : "") + fmt(p.daily_pnl_usdt, 2) + 'U</b></div>' +
'<div class="ax-tip-row"><span>累计收益</span><b class="' + cls(p.total_pnl_usdt) + '">' + (Number(p.total_pnl_usdt || 0) > 0 ? "+" : "") + fmt(p.total_pnl_usdt, 2) + 'U</b></div>';
tip.style.left = Math.max(96, Math.min(m.w - 96, px)) + "px";
tip.style.top = Math.max(42, py) + "px";
tip.classList.add("show");
}
function hide() {
if (locked) return;
activeIdx = null;
tip.classList.remove("show");
render();
}
root.addEventListener("mousemove", function (ev) {
if (locked) return;
activeIdx = nearestFromEvent(ev);
render();
});
root.addEventListener("mouseleave", hide);
root.addEventListener("click", function (ev) {
activeIdx = nearestFromEvent(ev);
locked = !locked;
render();
});
root.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") {
locked = false;
hide();
}
});
if (window.ResizeObserver) {
ro = new ResizeObserver(render);
ro.observe(root);
} else {
window.addEventListener("resize", render);
}
render();
root._axDestroy = function () {
if (ro) ro.disconnect();
window.removeEventListener("resize", render);
};
container._axDestroy = root._axDestroy;
}
function priceDecimals(ref) {
ref = Math.abs(Number(ref || 0));
if (ref >= 1000) return 1;
if (ref >= 100) return 2;
if (ref >= 1) return 4;
if (ref >= 0.01) return 6;
return 8;
}
function fmtPrice(v, decimals) {
v = Number(v || 0);
if (!Number.isFinite(v)) return "--";
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals == null ? priceDecimals(v) : decimals
});
}
function candleTime(c) {
var t = c && (c.time || c.timestamp || c.open_time || c.openTime || c.t);
if (typeof t === "string") {
var parsed = Date.parse(t);
return Number.isFinite(parsed) ? parsed : 0;
}
t = Number(t || 0);
return t > 0 && t < 100000000000 ? t * 1000 : t;
}
function fmtCandleDate(t) {
if (!t) return "--";
var d = new Date(t);
if (Number.isNaN(d.getTime())) return "--";
var mm = String(d.getMonth() + 1).padStart(2, "0");
var dd = String(d.getDate()).padStart(2, "0");
var hh = String(d.getHours()).padStart(2, "0");
var mi = String(d.getMinutes()).padStart(2, "0");
return mm + "-" + dd + " " + hh + ":" + mi;
}
function roundedRect(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r || 0, w / 2, h / 2));
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
}
function renderKline(container, payload) {
injectCss();
if (!container) return;
if (container._axDestroy) {
container._axDestroy();
container._axDestroy = null;
}
var candles = ((payload && payload.candles) || []).slice(-60).map(function (c) {
return {
time: candleTime(c),
open: Number(c.open || 0),
high: Number(c.high || 0),
low: Number(c.low || 0),
close: Number(c.close || 0),
volume: Number(c.volume || 0)
};
}).filter(function (c) {
return c.high > 0 && c.low > 0 && c.close > 0;
});
if (candles.length < 5) {
container.innerHTML = '<div class="chart-loading">K线数据不足</div>';
return;
}
var symbol = (payload && payload.symbol) || "";
var entryPrice = Number((payload && payload.entryPrice) || 0);
var stopLoss = Number((payload && payload.stopLoss) || 0);
var tp1 = Number((payload && payload.tp1) || 0);
var recTime = payload && payload.recTime ? Date.parse(payload.recTime) : 0;
var tp1Time = payload && payload.tp1Time ? Date.parse(payload.tp1Time) : 0;
var slTime = payload && payload.slTime ? Date.parse(payload.slTime) : 0;
var actionStatus = (payload && payload.actionStatus) || "";
var tradeMarkers = ((payload && payload.tradeMarkers) || []).map(function (marker) {
return {
time: marker && marker.time ? Date.parse(marker.time) : 0,
price: Number((marker && marker.price) || 0),
label: String((marker && marker.label) || "操作"),
type: String((marker && marker.type) || "event"),
note: String((marker && marker.note) || "")
};
}).filter(function (marker) {
return marker.time > 0;
});
var refPrice = Number((payload && payload.refPrice) || entryPrice || candles[candles.length - 1].close || 0);
var decimals = priceDecimals(refPrice || entryPrice || candles[candles.length - 1].close);
container.innerHTML = '<div class="ax-chart ax-kline" tabindex="0" aria-label="可交互K线图移动鼠标查看K线明细点击可锁定"><canvas></canvas><div class="ax-tooltip" aria-hidden="true"></div><div class="ax-hint">hover 查看K线</div></div>';
var root = container.querySelector(".ax-chart");
var canvas = root.querySelector("canvas");
var ctx = canvas.getContext("2d");
var tip = root.querySelector(".ax-tooltip");
var locked = false;
var activeIdx = null;
var ro = null;
var minPrice = Infinity, maxPrice = -Infinity, maxVol = 1;
candles.forEach(function (c) {
minPrice = Math.min(minPrice, c.low);
maxPrice = Math.max(maxPrice, c.high);
maxVol = Math.max(maxVol, c.volume || 0);
});
[entryPrice, stopLoss, tp1].forEach(function (p) {
if (p > 0) {
minPrice = Math.min(minPrice, p);
maxPrice = Math.max(maxPrice, p);
}
});
tradeMarkers.forEach(function (marker) {
if (marker.price > 0) {
minPrice = Math.min(minPrice, marker.price);
maxPrice = Math.max(maxPrice, marker.price);
}
});
var padding = Math.max((maxPrice - minPrice) * 0.06, maxPrice * 0.001);
minPrice -= padding;
maxPrice += padding;
if (maxPrice <= minPrice) maxPrice = minPrice + 1;
function metrics() {
var rect = root.getBoundingClientRect();
var w = Math.max(300, rect.width || container.clientWidth || 360);
var h = Math.max(190, rect.height || container.clientHeight || 200);
var padL = 4, padR = 4, padT = 12, padB = 20, volH = 32;
var chartH = Math.max(108, h - padT - padB - volH);
var chartW = Math.max(1, w - padL - padR);
return {w: w, h: h, padL: padL, padR: padR, padT: padT, padB: padB, volH: volH, chartH: chartH, chartW: chartW};
}
function resizeCanvas(m) {
var dpr = Math.max(1, window.devicePixelRatio || 1);
var bw = Math.round(m.w * dpr);
var bh = Math.round(m.h * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
canvas.style.width = m.w + "px";
canvas.style.height = m.h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function x(i, m) {
var step = m.chartW / candles.length;
return m.padL + step * i + step / 2;
}
function y(price, m) {
return m.padT + (maxPrice - price) * m.chartH / (maxPrice - minPrice);
}
function nearestIndexFromTime(ts) {
if (!ts) return -1;
var best = -1, diff = Infinity;
candles.forEach(function (c, i) {
var d = Math.abs(c.time - ts);
if (d < diff) {
diff = d;
best = i;
}
});
return best;
}
function drawGrid(m) {
ctx.strokeStyle = "rgba(148,163,184,.20)";
ctx.lineWidth = 1;
ctx.font = "800 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.textBaseline = "middle";
ctx.textAlign = "right";
for (var i = 0; i <= 3; i++) {
var gy = m.padT + m.chartH * i / 3;
var p = maxPrice - (maxPrice - minPrice) * i / 3;
ctx.beginPath();
ctx.moveTo(m.padL, gy);
ctx.lineTo(m.w - m.padR, gy);
ctx.stroke();
ctx.fillText(fmtPrice(p, decimals), m.w - m.padR - 4, gy + 8);
}
}
function drawCandles(m) {
var step = m.chartW / candles.length;
var cw = Math.max(2, Math.min(9, step * 0.62));
candles.forEach(function (c, i) {
var up = c.close >= c.open;
var cx = x(i, m);
var x0 = cx - cw / 2;
var yo = y(c.open, m), yc = y(c.close, m), yh = y(c.high, m), yl = y(c.low, m);
ctx.strokeStyle = up ? "rgba(0,180,115,.95)" : "rgba(229,62,62,.92)";
ctx.fillStyle = up ? "rgba(0,180,115,.86)" : "rgba(229,62,62,.84)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, yh);
ctx.lineTo(cx, yl);
ctx.stroke();
ctx.fillRect(x0, Math.min(yo, yc), cw, Math.max(1, Math.abs(yc - yo)));
var vh = Math.max(1, (c.volume || 0) / maxVol * (m.volH - 4));
ctx.fillStyle = up ? "rgba(0,180,115,.24)" : "rgba(229,62,62,.20)";
ctx.fillRect(x0, m.h - m.padB - vh, cw, vh);
});
}
function drawPriceMarker(m, value, label, markerColor) {
if (!(value > 0)) return;
var py = y(value, m);
ctx.save();
ctx.strokeStyle = markerColor;
ctx.globalAlpha = 0.75;
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(m.padL, py);
ctx.lineTo(m.w - m.padR, py);
ctx.stroke();
ctx.restore();
var text = label + " " + fmtPrice(value, decimals);
ctx.font = "900 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
var tw = ctx.measureText(text).width + 10;
ctx.fillStyle = "rgba(255,255,255,.88)";
ctx.strokeStyle = markerColor;
ctx.lineWidth = 1;
ctx.beginPath();
roundedRect(ctx, m.padL + 4, py - 9, tw, 18, 9);
ctx.fill();
ctx.stroke();
ctx.fillStyle = markerColor;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(text, m.padL + 9, py);
}
function drawEventMarker(m, ts, label, markerColor, price) {
var idx = nearestIndexFromTime(ts);
if (idx < 0) return;
var px = x(idx, m);
var baseY = m.h - m.padB - m.volH + 14;
ctx.save();
ctx.strokeStyle = markerColor;
ctx.globalAlpha = 0.78;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(px, y(candles[idx].low, m) + 3);
ctx.lineTo(px, baseY);
ctx.stroke();
ctx.restore();
ctx.font = "950 12px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = markerColor;
ctx.fillText(label, px, baseY + 9);
if (price > 0) {
ctx.font = "850 9px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.fillText("$" + fmtPrice(price, decimals), px, baseY + 22);
}
}
function markerColorFor(marker) {
var type = String((marker && marker.type) || "");
if (type === "open" || type === "fill") return color("--green", "#00b473");
if (type === "close") return color("--blue", "#4262ff");
if (type === "cancel") return color("--red", "#e53e3e");
if (type === "trailing") return "#a05a00";
if (type === "order") return color("--yellow-deep", "#fcb900");
return color("--stone", "#8e91a0");
}
function drawTradeMarker(m, marker, stack) {
var idx = nearestIndexFromTime(marker.time);
if (idx < 0) return;
var px = x(idx, m);
var rawY = marker.price > 0 ? y(marker.price, m) : y(candles[idx].close, m);
var py = Math.max(m.padT + 16, Math.min(m.padT + m.chartH - 12, rawY));
var markerColor = markerColorFor(marker);
var label = String(marker.label || "操作").slice(0, 4);
var labelY = Math.max(m.padT + 10, py - 20 - (stack % 3) * 18);
ctx.save();
ctx.strokeStyle = markerColor;
ctx.globalAlpha = 0.78;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(px, m.padT);
ctx.lineTo(px, m.h - m.padB - m.volH + 10);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(px, py, 4.5, 0, Math.PI * 2);
ctx.fillStyle = markerColor;
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,.92)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = "950 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
var tw = ctx.measureText(label).width + 12;
var x0 = Math.max(m.padL + 2, Math.min(m.w - m.padR - tw - 2, px - tw / 2));
ctx.fillStyle = "rgba(255,255,255,.92)";
ctx.strokeStyle = markerColor;
ctx.lineWidth = 1;
ctx.beginPath();
roundedRect(ctx, x0, labelY - 9, tw, 18, 9);
ctx.fill();
ctx.stroke();
ctx.fillStyle = markerColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, x0 + tw / 2, labelY);
}
function drawLabels(m) {
ctx.fillStyle = color("--stone", "#8e91a0");
ctx.font = "850 10px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(fmtCandleDate(candles[0].time), m.padL + 6, m.h - 10);
ctx.textAlign = "right";
ctx.fillText(fmtCandleDate(candles[candles.length - 1].time), m.w - m.padR - 6, m.h - 10);
}
function drawFocus(m) {
if (activeIdx == null) return;
var c = candles[activeIdx];
var px = x(activeIdx, m);
var py = y(c.close, m);
ctx.save();
ctx.strokeStyle = "rgba(15,23,42,.35)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(px, m.padT);
ctx.lineTo(px, m.h - m.padB);
ctx.moveTo(m.padL, py);
ctx.lineTo(m.w - m.padR, py);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI * 2);
ctx.fillStyle = color("--canvas", "#fff");
ctx.fill();
ctx.strokeStyle = color("--blue", "#4262ff");
ctx.lineWidth = 2;
ctx.stroke();
placeTip(c, px, py, m);
}
function render() {
var m = metrics();
resizeCanvas(m);
ctx.clearRect(0, 0, m.w, m.h);
drawGrid(m);
drawPriceMarker(m, tp1, "TP", color("--green", "#00b473"));
drawPriceMarker(m, entryPrice, actionStatus === "等回踩" ? "回踩" : "入场", color("--blue", "#4262ff"));
drawPriceMarker(m, stopLoss, "SL", color("--red", "#e53e3e"));
drawCandles(m);
if (tradeMarkers.length) {
var stacks = {};
tradeMarkers.forEach(function (marker) {
var idx = nearestIndexFromTime(marker.time);
if (idx < 0) return;
stacks[idx] = stacks[idx] || 0;
drawTradeMarker(m, marker, stacks[idx]);
stacks[idx] += 1;
});
} else {
if (recTime) drawEventMarker(m, recTime, actionStatus === "等回踩" ? "△" : "▲", actionStatus === "等回踩" ? "#8e91a0" : color("--blue", "#4262ff"), entryPrice);
if (tp1Time) drawEventMarker(m, tp1Time, "✓", color("--green", "#00b473"), tp1);
if (slTime) drawEventMarker(m, slTime, "×", color("--red", "#e53e3e"), stopLoss);
}
drawFocus(m);
drawLabels(m);
}
function nearestFromEvent(ev) {
var rect = canvas.getBoundingClientRect();
var sx = ev.clientX - rect.left;
var m = metrics();
var idx = Math.floor((sx - m.padL) / Math.max(1, m.chartW) * candles.length);
return Math.max(0, Math.min(candles.length - 1, idx));
}
function placeTip(c, px, py, m) {
var pct = c.open ? (c.close / c.open - 1) * 100 : 0;
tip.innerHTML =
'<div class="ax-tip-date">' + esc(symbol ? symbol + " · " : "") + esc(fmtCandleDate(c.time)) + '</div>' +
'<div class="ax-tip-row"><span>开</span><b>$' + fmtPrice(c.open, decimals) + '</b></div>' +
'<div class="ax-tip-row"><span>高 / 低</span><b>$' + fmtPrice(c.high, decimals) + ' / $' + fmtPrice(c.low, decimals) + '</b></div>' +
'<div class="ax-tip-row"><span>收</span><b class="' + cls(pct) + '">$' + fmtPrice(c.close, decimals) + ' (' + (pct > 0 ? "+" : "") + pct.toFixed(2) + '%)</b></div>' +
'<div class="ax-tip-row"><span>成交量</span><b>' + fmt(c.volume, 0) + '</b></div>';
tip.style.left = Math.max(96, Math.min(m.w - 96, px)) + "px";
tip.style.top = Math.max(42, py) + "px";
tip.classList.add("show");
}
function hide() {
if (locked) return;
activeIdx = null;
tip.classList.remove("show");
render();
}
root.addEventListener("mousemove", function (ev) {
if (locked) return;
activeIdx = nearestFromEvent(ev);
render();
});
root.addEventListener("mouseleave", hide);
root.addEventListener("click", function (ev) {
activeIdx = nearestFromEvent(ev);
locked = !locked;
render();
});
root.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") {
locked = false;
hide();
}
});
if (window.ResizeObserver) {
ro = new ResizeObserver(render);
ro.observe(root);
} else {
window.addEventListener("resize", render);
}
render();
root._axDestroy = function () {
if (ro) ro.disconnect();
window.removeEventListener("resize", render);
};
container._axDestroy = root._axDestroy;
}
return { renderEquity: renderEquity, renderKline: renderKline };
})();