Files
crypto_monitor/manual_trading_hub/static/chart_draw.js
T
2026-06-08 12:41:52 +08:00

723 lines
20 KiB
JavaScript

/**
* 行情区左侧画线工具(canvas 叠加层,坐标与 Lightweight Charts 对齐)。
*/
(function () {
const STORAGE_PREFIX = "hubMarketDraw:";
const HIT_PX = 8;
const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
const TOOL_LABELS = {
cursor: "光标",
hline: "水平线",
cross: "十字线",
channel: "平行通道",
rect: "矩形",
brush: "画笔",
range: "价格测距",
text: "文字",
fib: "斐波那契",
trend: "趋势线",
path: "折线箭头",
erase: "删除选中",
clear: "清除全部",
};
let chart = null;
let series = null;
let hostEl = null;
let mainEl = null;
let canvasEl = null;
let toolbarEl = null;
let viewKey = "";
let activeTool = "cursor";
let drawings = [];
let draft = null;
let selectedId = null;
let redrawRaf = 0;
let unsubRange = null;
function uid() {
return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
function storageKey() {
return STORAGE_PREFIX + (viewKey || "default");
}
function loadDrawings() {
try {
const raw = localStorage.getItem(storageKey());
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch (_) {
return [];
}
}
function saveDrawings() {
try {
localStorage.setItem(storageKey(), JSON.stringify(drawings));
} catch (_) {}
}
function setChartInteraction(enabled) {
if (!chart) return;
const on = !!enabled;
chart.applyOptions({
handleScroll: {
mouseWheel: on,
pressedMouseMove: on,
horzTouchDrag: on,
vertTouchDrag: false,
},
handleScale: {
axisPressedMouseMove: on,
mouseWheel: on,
pinch: on,
},
});
}
function syncCanvasSize() {
if (!canvasEl || !hostEl) return;
const w = hostEl.clientWidth;
const h = hostEl.clientHeight;
if (w < 1 || h < 1) return;
const dpr = window.devicePixelRatio || 1;
canvasEl.width = Math.floor(w * dpr);
canvasEl.height = Math.floor(h * dpr);
canvasEl.style.width = w + "px";
canvasEl.style.height = h + "px";
const ctx = canvasEl.getContext("2d");
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function timeToX(time) {
if (!chart || time == null) return null;
try {
return chart.timeScale().timeToCoordinate(time);
} catch (_) {
return null;
}
}
function priceToY(price) {
if (!series || price == null || !Number.isFinite(Number(price))) return null;
try {
return series.priceToCoordinate(Number(price));
} catch (_) {
return null;
}
}
function xyToPoint(x, y) {
if (!chart || !series) return null;
let time = null;
let price = null;
try {
time = chart.timeScale().coordinateToTime(x);
price = series.coordinateToPrice(y);
} catch (_) {}
if (time == null || price == null || !Number.isFinite(Number(price))) return null;
return { time: time, price: Number(price) };
}
function clientToLocal(ev) {
const rect = canvasEl.getBoundingClientRect();
return { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
}
function scheduleRedraw() {
if (redrawRaf) cancelAnimationFrame(redrawRaf);
redrawRaf = requestAnimationFrame(function () {
redrawRaf = 0;
redraw();
});
}
function strokeStyle(selected) {
return selected ? "#f59e0b" : "#60a5fa";
}
function drawLine(ctx, x1, y1, x2, y2, selected) {
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
ctx.beginPath();
ctx.strokeStyle = strokeStyle(selected);
ctx.lineWidth = selected ? 2 : 1.5;
ctx.setLineDash([]);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawHLine(ctx, y, w, selected) {
if (y == null) return;
drawLine(ctx, 0, y, w, y, selected);
}
function drawVLine(ctx, x, h, selected) {
if (x == null) return;
drawLine(ctx, x, 0, x, h, selected);
}
function drawRect(ctx, x1, y1, x2, y2, selected) {
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
const l = Math.min(x1, x2);
const t = Math.min(y1, y2);
const rw = Math.abs(x2 - x1);
const rh = Math.abs(y2 - y1);
ctx.strokeStyle = strokeStyle(selected);
ctx.lineWidth = selected ? 2 : 1.5;
ctx.setLineDash([]);
ctx.strokeRect(l, t, rw, rh);
ctx.fillStyle = selected ? "rgba(245,158,11,0.08)" : "rgba(96,165,250,0.06)";
ctx.fillRect(l, t, rw, rh);
}
function drawBrush(ctx, pts, selected) {
if (!pts || pts.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = strokeStyle(selected);
ctx.lineWidth = selected ? 2.5 : 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
let started = false;
pts.forEach(function (p) {
const x = timeToX(p.time);
const y = priceToY(p.price);
if (x == null || y == null) return;
if (!started) {
ctx.moveTo(x, y);
started = true;
} else {
ctx.lineTo(x, y);
}
});
if (started) ctx.stroke();
}
function drawFib(ctx, p1, p2, w, selected) {
if (!p1 || !p2) return;
const top = Math.max(p1.price, p2.price);
const bot = Math.min(p1.price, p2.price);
const x1 = timeToX(p1.time);
const x2 = timeToX(p2.time);
if (x1 == null || x2 == null) return;
const left = Math.min(x1, x2);
const right = Math.max(x1, x2);
FIB_LEVELS.forEach(function (lv) {
const price = bot + (top - bot) * (1 - lv);
const y = priceToY(price);
if (y == null) return;
ctx.beginPath();
ctx.strokeStyle = strokeStyle(selected);
ctx.lineWidth = 1;
ctx.setLineDash(lv === 0 || lv === 1 ? [] : [4, 4]);
ctx.moveTo(left, y);
ctx.lineTo(right, y);
ctx.stroke();
ctx.fillStyle = "#9aa4b8";
ctx.font = "10px sans-serif";
ctx.fillText((lv * 100).toFixed(1) + "%", left + 4, y - 3);
});
ctx.setLineDash([]);
}
function drawRange(ctx, p1, p2, selected) {
const y1 = priceToY(p1.price);
const y2 = priceToY(p2.price);
const x = timeToX(p1.time) != null ? timeToX(p1.time) : timeToX(p2.time);
if (y1 == null || y2 == null || x == null) return;
const top = Math.min(y1, y2);
const bot = Math.max(y1, y2);
ctx.strokeStyle = strokeStyle(selected);
ctx.fillStyle = selected ? "rgba(245,158,11,0.12)" : "rgba(96,165,250,0.1)";
ctx.lineWidth = 1.5;
ctx.fillRect(x - 14, top, 28, bot - top);
ctx.strokeRect(x - 14, top, 28, bot - top);
drawLine(ctx, x, top, x, bot, selected);
const pct = p1.price ? (((p2.price - p1.price) / p1.price) * 100).toFixed(2) : "0";
ctx.fillStyle = "#e2e8f0";
ctx.font = "10px sans-serif";
ctx.fillText(pct + "%", x + 18, (top + bot) / 2);
}
function drawText(ctx, p, text, selected) {
const x = timeToX(p.time);
const y = priceToY(p.price);
if (x == null || y == null) return;
ctx.font = "12px sans-serif";
ctx.fillStyle = strokeStyle(selected);
ctx.fillText(String(text || ""), x + 4, y - 4);
}
function parallelOffset(p1, p2, p3) {
const x1 = timeToX(p1.time);
const y1 = priceToY(p1.price);
const x2 = timeToX(p2.time);
const y2 = priceToY(p2.price);
const x3 = timeToX(p3.time);
const y3 = priceToY(p3.price);
if (x1 == null || y1 == null || x2 == null || y2 == null || x3 == null || y3 == null) {
return 0;
}
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
return (x3 - x1) * nx + (y3 - y1) * ny;
}
function drawChannel(ctx, p1, p2, offset, w, selected) {
const x1 = timeToX(p1.time);
const y1 = priceToY(p1.price);
const x2 = timeToX(p2.time);
const y2 = priceToY(p2.price);
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const ox = nx * offset;
const oy = ny * offset;
drawLine(ctx, x1, y1, x2, y2, selected);
drawLine(ctx, x1 + ox, y1 + oy, x2 + ox, y2 + oy, selected);
ctx.fillStyle = selected ? "rgba(245,158,11,0.06)" : "rgba(96,165,250,0.05)";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x2 + ox, y2 + oy);
ctx.lineTo(x1 + ox, y1 + oy);
ctx.closePath();
ctx.fill();
}
function drawPath(ctx, pts, selected) {
drawBrush(ctx, pts, selected);
if (!pts || pts.length < 2) return;
const last = pts[pts.length - 1];
const prev = pts[pts.length - 2];
const x1 = timeToX(prev.time);
const y1 = priceToY(prev.price);
const x2 = timeToX(last.time);
const y2 = priceToY(last.price);
if (x1 == null || y1 == null || x2 == null || y2 == null) return;
const ang = Math.atan2(y2 - y1, x2 - x1);
const al = 8;
ctx.beginPath();
ctx.fillStyle = strokeStyle(selected);
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - al * Math.cos(ang - 0.4), y2 - al * Math.sin(ang - 0.4));
ctx.lineTo(x2 - al * Math.cos(ang + 0.4), y2 - al * Math.sin(ang + 0.4));
ctx.closePath();
ctx.fill();
}
function renderDrawing(ctx, d, w, h, selected) {
const pts = d.points || [];
if (!pts.length) return;
switch (d.type) {
case "hline":
drawHLine(ctx, priceToY(pts[0].price), w, selected);
break;
case "cross":
drawHLine(ctx, priceToY(pts[0].price), w, selected);
drawVLine(ctx, timeToX(pts[0].time), h, selected);
break;
case "trend":
if (pts.length >= 2) {
drawLine(
ctx,
timeToX(pts[0].time),
priceToY(pts[0].price),
timeToX(pts[1].time),
priceToY(pts[1].price),
selected
);
}
break;
case "channel":
if (pts.length >= 3) {
drawChannel(ctx, pts[0], pts[1], d.offset || 0, w, selected);
} else if (pts.length === 2) {
drawLine(
ctx,
timeToX(pts[0].time),
priceToY(pts[0].price),
timeToX(pts[1].time),
priceToY(pts[1].price),
selected
);
}
break;
case "rect":
if (pts.length >= 2) {
drawRect(
ctx,
timeToX(pts[0].time),
priceToY(pts[0].price),
timeToX(pts[1].time),
priceToY(pts[1].price),
selected
);
}
break;
case "brush":
drawBrush(ctx, pts, selected);
break;
case "range":
if (pts.length >= 2) drawRange(ctx, pts[0], pts[1], selected);
break;
case "text":
drawText(ctx, pts[0], d.text, selected);
break;
case "fib":
if (pts.length >= 2) drawFib(ctx, pts[0], pts[1], w, selected);
break;
case "path":
drawPath(ctx, pts, selected);
break;
default:
break;
}
}
function redraw() {
if (!canvasEl) return;
syncCanvasSize();
const ctx = canvasEl.getContext("2d");
if (!ctx) return;
const w = hostEl.clientWidth;
const h = hostEl.clientHeight;
ctx.clearRect(0, 0, w, h);
drawings.forEach(function (d) {
renderDrawing(ctx, d, w, h, d.id === selectedId);
});
if (draft) renderDrawing(ctx, draft, w, h, true);
}
function commitDrawing(d) {
if (!d || !d.type) return;
d.id = uid();
drawings.push(d);
draft = null;
saveDrawings();
scheduleRedraw();
}
function pointsNeeded(tool) {
if (tool === "hline" || tool === "cross" || tool === "text") return 1;
if (tool === "trend" || tool === "rect" || tool === "range" || tool === "fib") return 2;
if (tool === "channel") return 3;
return 0;
}
function onPointerDown(ev) {
if (activeTool === "cursor" || activeTool === "clear" || activeTool === "erase") return;
if (!chart || !series) return;
const loc = clientToLocal(ev);
const pt = xyToPoint(loc.x, loc.y);
if (!pt) return;
ev.preventDefault();
if (activeTool === "brush") {
draft = { type: "brush", points: [pt] };
return;
}
if (activeTool === "path") {
if (!draft || draft.type !== "path") {
draft = { type: "path", points: [pt] };
} else {
draft.points.push(pt);
}
scheduleRedraw();
return;
}
if (!draft) {
draft = { type: activeTool, points: [pt] };
if (activeTool === "text") {
const text = window.prompt("输入标注文字", "");
if (text && String(text).trim()) {
draft.text = String(text).trim();
commitDrawing(draft);
} else {
draft = null;
}
} else if (pointsNeeded(activeTool) === 1) {
commitDrawing(draft);
}
scheduleRedraw();
return;
}
draft.points.push(pt);
if (activeTool === "channel" && draft.points.length === 3) {
draft.offset = parallelOffset(draft.points[0], draft.points[1], draft.points[2]);
commitDrawing(draft);
return;
}
if (draft.points.length >= pointsNeeded(activeTool)) {
commitDrawing(draft);
}
scheduleRedraw();
}
function onPointerMove(ev) {
if (!draft) return;
const loc = clientToLocal(ev);
const pt = xyToPoint(loc.x, loc.y);
if (!pt) return;
if (activeTool === "brush" && draft.type === "brush") {
const last = draft.points[draft.points.length - 1];
const lx = timeToX(last.time);
const ly = priceToY(last.price);
const cx = timeToX(pt.time);
const cy = priceToY(pt.price);
if (lx != null && ly != null && cx != null && cy != null && Math.hypot(cx - lx, cy - ly) > 3) {
draft.points.push(pt);
scheduleRedraw();
}
return;
}
if (pointsNeeded(activeTool) >= 2 && draft.points.length >= 1) {
draft.points[draft.points.length] = pt;
if (draft.points.length > pointsNeeded(activeTool)) {
draft.points.length = pointsNeeded(activeTool);
}
scheduleRedraw();
}
}
function onPointerUp(ev) {
if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) {
commitDrawing(draft);
}
}
function onDblClick(ev) {
if (activeTool === "path" && draft && draft.type === "path" && draft.points.length > 1) {
commitDrawing(draft);
ev.preventDefault();
}
}
function distSeg(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1);
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)));
const nx = x1 + t * dx;
const ny = y1 + t * dy;
return Math.hypot(px - nx, py - ny);
}
function hitTestDrawing(d, x, y) {
const pts = d.points || [];
if (!pts.length) return false;
const w = hostEl.clientWidth;
const h = hostEl.clientHeight;
switch (d.type) {
case "hline": {
const ly = priceToY(pts[0].price);
return ly != null && Math.abs(y - ly) <= HIT_PX;
}
case "cross": {
const ly = priceToY(pts[0].price);
const lx = timeToX(pts[0].time);
return (
(ly != null && Math.abs(y - ly) <= HIT_PX) ||
(lx != null && Math.abs(x - lx) <= HIT_PX)
);
}
case "trend":
case "channel":
if (pts.length >= 2) {
const x1 = timeToX(pts[0].time);
const y1 = priceToY(pts[0].price);
const x2 = timeToX(pts[1].time);
const y2 = priceToY(pts[1].price);
if (x1 != null && y1 != null && x2 != null && y2 != null) {
if (distSeg(x, y, x1, y1, x2, y2) <= HIT_PX) return true;
}
}
return false;
case "rect":
if (pts.length >= 2) {
const x1 = timeToX(pts[0].time);
const y1 = priceToY(pts[0].price);
const x2 = timeToX(pts[1].time);
const y2 = priceToY(pts[1].price);
if (x1 == null || y1 == null || x2 == null || y2 == null) return false;
const l = Math.min(x1, x2) - HIT_PX;
const r = Math.max(x1, x2) + HIT_PX;
const t = Math.min(y1, y2) - HIT_PX;
const b = Math.max(y1, y2) + HIT_PX;
return x >= l && x <= r && y >= t && y <= b;
}
return false;
case "text": {
const tx = timeToX(pts[0].time);
const ty = priceToY(pts[0].price);
return tx != null && ty != null && Math.hypot(x - tx, y - ty) <= 14;
}
default:
return false;
}
}
function passthroughPointerToChart(ev) {
if (!hostEl) return;
canvasEl.style.pointerEvents = "none";
const target = document.elementFromPoint(ev.clientX, ev.clientY);
if (target && target !== canvasEl) {
const opts = {
bubbles: true,
cancelable: true,
clientX: ev.clientX,
clientY: ev.clientY,
pointerId: ev.pointerId,
pointerType: ev.pointerType,
buttons: ev.buttons,
};
target.dispatchEvent(new PointerEvent(ev.type, opts));
}
const restore = function () {
if (activeTool === "cursor") canvasEl.style.pointerEvents = "auto";
window.removeEventListener("pointerup", restore, true);
};
window.addEventListener("pointerup", restore, true);
}
function onCursorPointerDown(ev) {
if (activeTool !== "cursor") return;
const loc = clientToLocal(ev);
for (let i = drawings.length - 1; i >= 0; i--) {
if (hitTestDrawing(drawings[i], loc.x, loc.y)) {
selectedId = drawings[i].id;
scheduleRedraw();
ev.preventDefault();
ev.stopPropagation();
return;
}
}
selectedId = null;
scheduleRedraw();
passthroughPointerToChart(ev);
}
function setActiveTool(tool) {
if (!TOOL_LABELS[tool]) return;
if (tool === "clear") {
if (drawings.length && window.confirm("清除当前图表上的全部画线?")) {
drawings = [];
selectedId = null;
draft = null;
saveDrawings();
scheduleRedraw();
}
return;
}
if (tool === "erase") {
if (selectedId) {
drawings = drawings.filter(function (d) {
return d.id !== selectedId;
});
selectedId = null;
saveDrawings();
scheduleRedraw();
}
return;
}
activeTool = tool;
draft = null;
if (toolbarEl) {
toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) {
btn.classList.toggle("is-active", btn.getAttribute("data-tool") === tool);
});
}
const drawing = tool !== "cursor";
if (canvasEl) {
canvasEl.classList.toggle("is-drawing", drawing);
canvasEl.style.pointerEvents = "auto";
}
setChartInteraction(!drawing);
scheduleRedraw();
}
function bindToolbar() {
if (!toolbarEl) return;
toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) {
btn.addEventListener("click", function () {
setActiveTool(btn.getAttribute("data-tool") || "cursor");
});
});
}
function bindCanvas() {
if (!canvasEl) return;
canvasEl.addEventListener("pointerdown", function (ev) {
if (activeTool === "cursor") {
onCursorPointerDown(ev);
return;
}
onPointerDown(ev);
});
canvasEl.addEventListener("pointermove", onPointerMove);
canvasEl.addEventListener("pointerup", onPointerUp);
canvasEl.addEventListener("dblclick", onDblClick);
}
function attach(opts) {
chart = opts.chart || null;
series = opts.series || null;
hostEl = opts.hostEl || null;
mainEl = opts.mainEl || null;
canvasEl = opts.canvasEl || null;
toolbarEl = opts.toolbarEl || null;
bindToolbar();
bindCanvas();
setActiveTool("cursor");
if (chart && chart.timeScale) {
if (unsubRange) {
try {
unsubRange();
} catch (_) {}
}
unsubRange = chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
scheduleRedraw();
});
}
scheduleRedraw();
}
function setViewKey(key) {
viewKey = key || "";
drawings = loadDrawings();
selectedId = null;
draft = null;
scheduleRedraw();
}
function destroy() {
if (unsubRange) {
try {
unsubRange();
} catch (_) {}
unsubRange = null;
}
setChartInteraction(true);
}
window.HubChartDraw = {
attach: attach,
setViewKey: setViewKey,
resize: scheduleRedraw,
redraw: scheduleRedraw,
destroy: destroy,
};
})();