626 lines
23 KiB
JavaScript
626 lines
23 KiB
JavaScript
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 {"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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 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);
|
||
}
|
||
});
|
||
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 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 (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 };
|
||
})();
|