6eb17b7ddc
Add toggle before technical indicators to show blue dashed vertical lines at Beijing 8:00 day boundaries. Co-authored-by: Cursor <cursoragent@cursor.com>
1463 lines
42 KiB
JavaScript
1463 lines
42 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, 0.886, 1];
|
|
const FIB_LINE_COLORS = {
|
|
0: "#787b86",
|
|
0.236: "#f23645",
|
|
0.382: "#e6b422",
|
|
0.5: "#5d606b",
|
|
0.618: "#d97706",
|
|
0.786: "#26a69a",
|
|
0.886: "#9c27b0",
|
|
1: "#11734b",
|
|
};
|
|
const FIB_ZONE_FILLS = [
|
|
{ top: 1, bot: 0.886, fill: "rgba(156, 39, 176, 0.14)" },
|
|
{ top: 0.886, bot: 0.786, fill: "rgba(38, 166, 154, 0.14)" },
|
|
{ top: 0.786, bot: 0.618, fill: "rgba(0, 188, 212, 0.14)" },
|
|
{ top: 0.618, bot: 0.5, fill: "rgba(244, 143, 177, 0.16)" },
|
|
{ top: 0.5, bot: 0.382, fill: "rgba(120, 123, 134, 0.12)" },
|
|
{ top: 0.382, bot: 0.236, fill: "rgba(255, 183, 77, 0.16)" },
|
|
{ top: 0.236, bot: 0, fill: "rgba(242, 54, 69, 0.12)" },
|
|
];
|
|
const DRAG_TOOLS = new Set(["trend", "rect", "range", "fib"]);
|
|
const ONE_SHOT_TOOLS = new Set([
|
|
"hline", "cross", "channel", "rect", "brush", "range", "text", "fib", "trend", "path", "erase",
|
|
]);
|
|
const MIN_DRAG_PX = 6;
|
|
|
|
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;
|
|
let getCandlesFn = null;
|
|
let brushPointerId = null;
|
|
let dragActive = false;
|
|
let dragStartPx = null;
|
|
let pathPreviewPt = null;
|
|
let menuEl = null;
|
|
let unsubClick = null;
|
|
let mainBound = false;
|
|
let tradingDaySplitEnabled = false;
|
|
const BJ_OFFSET_SEC = 8 * 60 * 60;
|
|
|
|
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 getCandles() {
|
|
if (typeof getCandlesFn === "function") {
|
|
const rows = getCandlesFn();
|
|
return Array.isArray(rows) ? rows : [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function timeToX(time) {
|
|
if (!chart || time == null) return null;
|
|
try {
|
|
const x = chart.timeScale().timeToCoordinate(time);
|
|
return x == null || !Number.isFinite(x) ? null : x;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function priceToY(price) {
|
|
if (!series || price == null || !Number.isFinite(Number(price))) return null;
|
|
try {
|
|
const y = series.priceToCoordinate(Number(price));
|
|
return y == null || !Number.isFinite(y) ? null : y;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function xToTime(x) {
|
|
if (!chart) return null;
|
|
try {
|
|
const direct = chart.timeScale().coordinateToTime(x);
|
|
if (direct != null) return direct;
|
|
} catch (_) {}
|
|
const candles = getCandles();
|
|
if (!candles.length) return null;
|
|
let bestTime = candles[0].time;
|
|
let bestDist = Infinity;
|
|
candles.forEach(function (c) {
|
|
const cx = timeToX(c.time);
|
|
if (cx == null) return;
|
|
const d = Math.abs(cx - x);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
bestTime = c.time;
|
|
}
|
|
});
|
|
return bestTime;
|
|
}
|
|
|
|
function yToPrice(y) {
|
|
if (!series) return null;
|
|
try {
|
|
const direct = series.coordinateToPrice(y);
|
|
if (direct != null && Number.isFinite(Number(direct))) return Number(direct);
|
|
} catch (_) {}
|
|
const candles = getCandles();
|
|
if (!candles.length) return null;
|
|
let lo = null;
|
|
let hi = null;
|
|
candles.forEach(function (c) {
|
|
const vals = [c.low, c.high, c.open, c.close];
|
|
vals.forEach(function (v) {
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return;
|
|
if (lo == null || n < lo) lo = n;
|
|
if (hi == null || n > hi) hi = n;
|
|
});
|
|
});
|
|
if (lo == null || hi == null) return null;
|
|
const yLo = priceToY(lo);
|
|
const yHi = priceToY(hi);
|
|
if (yLo == null || yHi == null || Math.abs(yHi - yLo) < 1e-6) return (lo + hi) / 2;
|
|
const ratio = (y - yLo) / (yHi - yLo);
|
|
return lo + (hi - lo) * ratio;
|
|
}
|
|
|
|
function xyToPoint(x, y) {
|
|
if (!chart || !series) return null;
|
|
const time = xToTime(x);
|
|
const price = yToPrice(y);
|
|
if (time == null || price == null || !Number.isFinite(price)) return null;
|
|
return { time: time, price: price };
|
|
}
|
|
|
|
function clientToLocal(ev) {
|
|
const rect = (hostEl || canvasEl).getBoundingClientRect();
|
|
return { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
|
|
}
|
|
|
|
function mountCanvasOverlay() {
|
|
if (!canvasEl || !hostEl) return;
|
|
if (canvasEl.parentElement !== hostEl) {
|
|
hostEl.appendChild(canvasEl);
|
|
}
|
|
canvasEl.style.position = "absolute";
|
|
canvasEl.style.top = "0";
|
|
canvasEl.style.left = "0";
|
|
canvasEl.style.width = "100%";
|
|
canvasEl.style.height = "100%";
|
|
}
|
|
|
|
function scheduleRedraw() {
|
|
if (redrawRaf) cancelAnimationFrame(redrawRaf);
|
|
redrawRaf = requestAnimationFrame(function () {
|
|
redrawRaf = 0;
|
|
redraw();
|
|
});
|
|
}
|
|
|
|
function strokeStyle(selected) {
|
|
return selected ? "#f59e0b" : "#60a5fa";
|
|
}
|
|
|
|
function formatPrice(p) {
|
|
const n = Number(p);
|
|
if (!Number.isFinite(n)) return "—";
|
|
const a = Math.abs(n);
|
|
if (a >= 10000) return n.toFixed(2);
|
|
if (a >= 1) return n.toFixed(4);
|
|
return n.toFixed(6);
|
|
}
|
|
|
|
function signedPrice(n) {
|
|
const s = formatPrice(Math.abs(n));
|
|
return n < 0 ? "-" + s : s;
|
|
}
|
|
|
|
function estimateTickSize(price) {
|
|
const a = Math.abs(Number(price)) || 1;
|
|
if (a >= 10000) return 0.01;
|
|
if (a >= 100) return 0.01;
|
|
if (a >= 1) return 0.0001;
|
|
if (a >= 0.01) return 0.000001;
|
|
return 0.0000001;
|
|
}
|
|
|
|
function tickCount(diff, refPrice) {
|
|
const step = estimateTickSize(refPrice);
|
|
if (!step) return 0;
|
|
return Math.round(diff / step);
|
|
}
|
|
|
|
function fibPriceAt(top, bot, lv) {
|
|
return bot + (top - bot) * (1 - lv);
|
|
}
|
|
|
|
function drawHandle(ctx, x, y, large, color) {
|
|
if (x == null || y == null) return;
|
|
const r = large ? 6 : 4.5;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fill();
|
|
ctx.strokeStyle = color || "#2962ff";
|
|
ctx.lineWidth = large ? 2 : 1.5;
|
|
ctx.stroke();
|
|
}
|
|
|
|
function roundBadge(ctx, x, y, text) {
|
|
ctx.font = "11px sans-serif";
|
|
const padX = 8;
|
|
const padY = 5;
|
|
const tw = ctx.measureText(text).width;
|
|
const bw = tw + padX * 2;
|
|
const bh = 20;
|
|
const left = x - bw / 2;
|
|
const top = y - bh / 2;
|
|
ctx.fillStyle = "rgba(30, 58, 138, 0.92)";
|
|
ctx.beginPath();
|
|
const r = 4;
|
|
ctx.moveTo(left + r, top);
|
|
ctx.lineTo(left + bw - r, top);
|
|
ctx.quadraticCurveTo(left + bw, top, left + bw, top + r);
|
|
ctx.lineTo(left + bw, top + bh - r);
|
|
ctx.quadraticCurveTo(left + bw, top + bh, left + bw - r, top + bh);
|
|
ctx.lineTo(left + r, top + bh);
|
|
ctx.quadraticCurveTo(left, top + bh, left, top + bh - r);
|
|
ctx.lineTo(left, top + r);
|
|
ctx.quadraticCurveTo(left, top, left + r, top);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.fillStyle = "#f8fafc";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(text, x, y + 1);
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "alphabetic";
|
|
}
|
|
|
|
function isDragTool(tool) {
|
|
return DRAG_TOOLS.has(tool);
|
|
}
|
|
|
|
function cancelDraft() {
|
|
draft = null;
|
|
dragActive = false;
|
|
dragStartPx = null;
|
|
pathPreviewPt = null;
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function returnToCursorIfOneShot() {
|
|
if (ONE_SHOT_TOOLS.has(activeTool)) {
|
|
setActiveTool("cursor");
|
|
}
|
|
}
|
|
|
|
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 utcSecToBjParts(utcSec) {
|
|
const d = new Date((Number(utcSec) + BJ_OFFSET_SEC) * 1000);
|
|
return {
|
|
y: d.getUTCFullYear(),
|
|
m: d.getUTCMonth(),
|
|
d: d.getUTCDate(),
|
|
h: d.getUTCHours(),
|
|
};
|
|
}
|
|
|
|
function collectTradingDayBoundaries(candles) {
|
|
if (!candles.length) return [];
|
|
const minT = Number(candles[0].time);
|
|
const maxT = Number(candles[candles.length - 1].time);
|
|
const minP = utcSecToBjParts(minT);
|
|
const maxP = utcSecToBjParts(maxT);
|
|
const out = [];
|
|
let curMs = Date.UTC(minP.y, minP.m, minP.d) - 86400000;
|
|
const endMs = Date.UTC(maxP.y, maxP.m, maxP.d) + 2 * 86400000;
|
|
while (curMs <= endMs) {
|
|
const boundary = Math.floor(curMs / 1000);
|
|
if (boundary >= minT - 3600 && boundary <= maxT + 3600) {
|
|
if (!out.length || out[out.length - 1] !== boundary) {
|
|
out.push(boundary);
|
|
}
|
|
}
|
|
curMs += 86400000;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function drawTradingDaySplits(ctx, w, h) {
|
|
if (!tradingDaySplitEnabled || !chart) return;
|
|
const candles = getCandles();
|
|
if (!candles.length) return;
|
|
const boundaries = collectTradingDayBoundaries(candles);
|
|
if (!boundaries.length) return;
|
|
ctx.save();
|
|
ctx.strokeStyle = "#3b82f6";
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([5, 4]);
|
|
boundaries.forEach(function (t) {
|
|
const x = timeToX(t);
|
|
if (x == null || !Number.isFinite(x) || x < -2 || x > w + 2) return;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, h);
|
|
ctx.stroke();
|
|
});
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
}
|
|
|
|
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);
|
|
const y1 = priceToY(p1.price);
|
|
const y2 = priceToY(p2.price);
|
|
const yTop = priceToY(top);
|
|
const yBot = priceToY(bot);
|
|
if (x1 == null || x2 == null || yTop == null || yBot == null || y1 == null || y2 == null) return;
|
|
const left = Math.min(x1, x2);
|
|
const right = Math.max(x1, x2);
|
|
const span = Math.max(right - left, 48);
|
|
const drawRight = left + span;
|
|
|
|
FIB_ZONE_FILLS.forEach(function (zone) {
|
|
const yA = priceToY(fibPriceAt(top, bot, zone.top));
|
|
const yB = priceToY(fibPriceAt(top, bot, zone.bot));
|
|
if (yA == null || yB == null) return;
|
|
const zt = Math.min(yA, yB);
|
|
const zb = Math.max(yA, yB);
|
|
ctx.fillStyle = zone.fill;
|
|
ctx.fillRect(left, zt, drawRight - left, Math.max(zb - zt, 1));
|
|
});
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = "rgba(120, 123, 134, 0.7)";
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.strokeStyle = selected ? "#f59e0b" : "#787b86";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(left, yTop);
|
|
ctx.lineTo(left, yBot);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(drawRight, yTop);
|
|
ctx.lineTo(drawRight, yBot);
|
|
ctx.stroke();
|
|
|
|
let lastLabelY = -9999;
|
|
FIB_LEVELS.forEach(function (lv) {
|
|
const price = fibPriceAt(top, bot, lv);
|
|
const y = priceToY(price);
|
|
if (y == null) return;
|
|
const lineColor = FIB_LINE_COLORS[lv] || "#787b86";
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = lineColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash(lv === 0 || lv === 1 ? [] : [5, 4]);
|
|
ctx.moveTo(left, y);
|
|
ctx.lineTo(drawRight, y);
|
|
ctx.stroke();
|
|
if (Math.abs(y - lastLabelY) < 13) return;
|
|
lastLabelY = y;
|
|
const lvLabel = lv === 1 || lv === 0 ? String(lv) : String(lv);
|
|
ctx.fillStyle = lineColor;
|
|
ctx.font = "10px sans-serif";
|
|
ctx.fillText(lvLabel + " (" + formatPrice(price) + ")", drawRight + 6, y + 3);
|
|
});
|
|
ctx.setLineDash([]);
|
|
if (selected) {
|
|
drawHandle(ctx, x1, y1, false, "#2962ff");
|
|
drawHandle(ctx, x2, y2, false, "#2962ff");
|
|
}
|
|
}
|
|
|
|
function drawRange(ctx, p1, p2, selected) {
|
|
const x1 = timeToX(p1.time);
|
|
const x2 = timeToX(p2.time);
|
|
const y1 = priceToY(p1.price);
|
|
const y2 = priceToY(p2.price);
|
|
if (x1 == null || x2 == null || y1 == null || y2 == null) return;
|
|
const left = Math.min(x1, x2);
|
|
const right = Math.max(x1, x2);
|
|
const top = Math.min(y1, y2);
|
|
const bot = Math.max(y1, y2);
|
|
const boxW = Math.max(right - left, 10);
|
|
const boxH = Math.max(bot - top, 6);
|
|
const borderColor = selected ? "#f59e0b" : "#1e293b";
|
|
|
|
ctx.fillStyle = selected ? "rgba(245,158,11,0.18)" : "rgba(45, 212, 191, 0.22)";
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([]);
|
|
ctx.fillRect(left, top, boxW, boxH);
|
|
ctx.strokeRect(left, top, boxW, boxH);
|
|
|
|
const midX = left + boxW / 2;
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.moveTo(midX, top);
|
|
ctx.lineTo(midX, bot);
|
|
ctx.stroke();
|
|
const arrowY = bot + 2;
|
|
ctx.beginPath();
|
|
ctx.fillStyle = borderColor;
|
|
ctx.moveTo(midX, arrowY + 7);
|
|
ctx.lineTo(midX - 5, arrowY);
|
|
ctx.lineTo(midX + 5, arrowY);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
const diff = p2.price - p1.price;
|
|
const pct = p1.price ? (diff / p1.price) * 100 : 0;
|
|
const ticks = tickCount(diff, p1.price);
|
|
const tickStr = ticks < 0 ? String(ticks) : ticks > 0 ? "+" + ticks : "0";
|
|
const badgeText =
|
|
signedPrice(diff) + " (" + (pct >= 0 ? "+" : "") + pct.toFixed(2) + "%) " + tickStr;
|
|
roundBadge(ctx, midX, bot + 22, badgeText);
|
|
|
|
drawHandle(ctx, left, top, false, "#2962ff");
|
|
drawHandle(ctx, right, bot, false, "#2962ff");
|
|
}
|
|
|
|
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, previewPt) {
|
|
if (!pts || !pts.length) return;
|
|
const color = strokeStyle(selected);
|
|
const coords = [];
|
|
pts.forEach(function (p) {
|
|
const x = timeToX(p.time);
|
|
const y = priceToY(p.price);
|
|
if (x != null && y != null) coords.push({ x: x, y: y });
|
|
});
|
|
if (coords.length < 1) return;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = selected ? 2 : 1.5;
|
|
ctx.lineJoin = "round";
|
|
ctx.lineCap = "round";
|
|
ctx.setLineDash([]);
|
|
ctx.moveTo(coords[0].x, coords[0].y);
|
|
for (let i = 1; i < coords.length; i++) {
|
|
ctx.lineTo(coords[i].x, coords[i].y);
|
|
}
|
|
if (coords.length > 1) ctx.stroke();
|
|
|
|
if (previewPt) {
|
|
const px = timeToX(previewPt.time);
|
|
const py = priceToY(previewPt.price);
|
|
const last = coords[coords.length - 1];
|
|
if (px != null && py != null && last) {
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([5, 4]);
|
|
ctx.moveTo(last.x, last.y);
|
|
ctx.lineTo(px, py);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
drawHandle(ctx, px, py, true, color);
|
|
}
|
|
}
|
|
|
|
coords.forEach(function (c, idx) {
|
|
const isLast = idx === coords.length - 1;
|
|
drawHandle(ctx, c.x, c.y, isLast && !!previewPt, color);
|
|
});
|
|
}
|
|
|
|
function renderDrawing(ctx, d, w, h, selected, previewPt) {
|
|
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, previewPt || null);
|
|
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);
|
|
drawTradingDaySplits(ctx, w, h);
|
|
drawings.forEach(function (d) {
|
|
if (d.hidden) ctx.globalAlpha = 0.14;
|
|
renderDrawing(ctx, d, w, h, d.id === selectedId);
|
|
if (d.hidden) ctx.globalAlpha = 1;
|
|
});
|
|
if (draft) {
|
|
const preview = draft.type === "path" ? pathPreviewPt : null;
|
|
renderDrawing(ctx, draft, w, h, true, preview);
|
|
}
|
|
if (activeTool === "cursor" && selectedId) {
|
|
const sel = getDrawingById(selectedId);
|
|
if (sel) drawSelectionOverlay(ctx, sel);
|
|
}
|
|
}
|
|
|
|
function commitDrawing(d) {
|
|
if (!d || !d.type) return;
|
|
d.id = uid();
|
|
drawings.push(d);
|
|
selectedId = d.id;
|
|
draft = null;
|
|
pathPreviewPt = null;
|
|
saveDrawings();
|
|
scheduleRedraw();
|
|
returnToCursorIfOneShot();
|
|
}
|
|
|
|
function finishPath() {
|
|
if (draft && draft.type === "path" && draft.points.length > 1) {
|
|
commitDrawing({ type: "path", points: draft.points.slice() });
|
|
} else {
|
|
cancelDraft();
|
|
}
|
|
}
|
|
|
|
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 getDrawingById(id) {
|
|
for (let i = 0; i < drawings.length; i++) {
|
|
if (drawings[i].id === id) return drawings[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pickDrawingAt(x, y) {
|
|
for (let i = drawings.length - 1; i >= 0; i--) {
|
|
if (hitTestDrawing(drawings[i], x, y)) return drawings[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function selectDrawing(id) {
|
|
selectedId = id || null;
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function deselectDrawing() {
|
|
if (!selectedId) return;
|
|
selectedId = null;
|
|
hideContextMenu();
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function removeDrawing(id, opts) {
|
|
if (!id) return;
|
|
const force = !!(opts && opts.force);
|
|
const d = getDrawingById(id);
|
|
if (!d) return;
|
|
if (d.locked && !force) return;
|
|
drawings = drawings.filter(function (item) {
|
|
return item.id !== id;
|
|
});
|
|
if (selectedId === id) selectedId = null;
|
|
hideContextMenu();
|
|
saveDrawings();
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function removeSelectedDrawing() {
|
|
if (!selectedId) return;
|
|
removeDrawing(selectedId);
|
|
}
|
|
|
|
function cloneDrawing(id) {
|
|
const src = getDrawingById(id);
|
|
if (!src || src.locked) return;
|
|
const copy = JSON.parse(JSON.stringify(src));
|
|
copy.id = uid();
|
|
copy.locked = false;
|
|
const candles = getCandles();
|
|
const timeStep = candles.length > 1 ? Math.abs(candles[1].time - candles[0].time) : 60;
|
|
copy.points = (copy.points || []).map(function (p, idx) {
|
|
return {
|
|
time: p.time + timeStep * (idx + 1),
|
|
price: p.price * 1.001,
|
|
};
|
|
});
|
|
if (copy.text) copy.text = String(copy.text);
|
|
drawings.push(copy);
|
|
selectedId = copy.id;
|
|
saveDrawings();
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function toggleDrawingLock(id) {
|
|
const d = getDrawingById(id);
|
|
if (!d) return;
|
|
d.locked = !d.locked;
|
|
saveDrawings();
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function toggleDrawingHide(id) {
|
|
const d = getDrawingById(id);
|
|
if (!d) return;
|
|
d.hidden = !d.hidden;
|
|
if (d.hidden && selectedId === id) selectedId = null;
|
|
hideContextMenu();
|
|
saveDrawings();
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function isTypingTarget(el) {
|
|
if (!el) return false;
|
|
const tag = (el.tagName || "").toUpperCase();
|
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!el.isContentEditable;
|
|
}
|
|
|
|
function ensureContextMenu() {
|
|
if (menuEl) return menuEl;
|
|
menuEl = document.createElement("div");
|
|
menuEl.id = "market-draw-menu";
|
|
menuEl.className = "market-draw-menu hidden";
|
|
menuEl.setAttribute("role", "menu");
|
|
document.body.appendChild(menuEl);
|
|
menuEl.addEventListener("click", function (ev) {
|
|
const btn = ev.target.closest("[data-action]");
|
|
if (!btn || btn.disabled) return;
|
|
const action = btn.getAttribute("data-action");
|
|
const id = menuEl._targetId;
|
|
if (!id) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
if (action === "clone") cloneDrawing(id);
|
|
else if (action === "toggle-lock") toggleDrawingLock(id);
|
|
else if (action === "toggle-hide") toggleDrawingHide(id);
|
|
else if (action === "remove") removeDrawing(id, { force: true });
|
|
if (action !== "remove" && action !== "toggle-hide") {
|
|
const d = getDrawingById(id);
|
|
if (d) showContextMenu(menuEl._clientX, menuEl._clientY, d);
|
|
else hideContextMenu();
|
|
}
|
|
});
|
|
document.addEventListener("pointerdown", function (ev) {
|
|
if (!menuEl || menuEl.classList.contains("hidden")) return;
|
|
if (!menuEl.contains(ev.target)) hideContextMenu();
|
|
});
|
|
return menuEl;
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
if (!menuEl) return;
|
|
menuEl.classList.add("hidden");
|
|
menuEl._targetId = null;
|
|
}
|
|
|
|
function showContextMenu(clientX, clientY, d) {
|
|
if (!d) return;
|
|
const menu = ensureContextMenu();
|
|
const label = TOOL_LABELS[d.type] || d.type;
|
|
const locked = !!d.locked;
|
|
menu.innerHTML =
|
|
'<div class="market-draw-menu-head">' +
|
|
label +
|
|
"</div>" +
|
|
'<button type="button" class="market-draw-menu-item" data-action="clone"' +
|
|
(locked ? " disabled" : "") +
|
|
">克隆</button>" +
|
|
'<button type="button" class="market-draw-menu-item" data-action="toggle-lock">' +
|
|
(locked ? "解锁" : "锁定") +
|
|
"</button>" +
|
|
'<button type="button" class="market-draw-menu-item" data-action="toggle-hide">' +
|
|
(d.hidden ? "显示" : "隐藏") +
|
|
"</button>" +
|
|
'<hr class="market-draw-menu-sep" />' +
|
|
'<button type="button" class="market-draw-menu-item is-danger" data-action="remove">移除 <span class="market-draw-menu-kbd">Del</span></button>';
|
|
menu.classList.remove("hidden");
|
|
menu._targetId = d.id;
|
|
menu._clientX = clientX;
|
|
menu._clientY = clientY;
|
|
menu.style.visibility = "hidden";
|
|
menu.style.left = "0px";
|
|
menu.style.top = "0px";
|
|
const mw = menu.offsetWidth;
|
|
const mh = menu.offsetHeight;
|
|
const pad = 8;
|
|
let left = clientX;
|
|
let top = clientY;
|
|
if (left + mw > window.innerWidth - pad) left = window.innerWidth - mw - pad;
|
|
if (top + mh > window.innerHeight - pad) top = window.innerHeight - mh - pad;
|
|
if (left < pad) left = pad;
|
|
if (top < pad) top = pad;
|
|
menu.style.left = left + "px";
|
|
menu.style.top = top + "px";
|
|
menu.style.visibility = "";
|
|
}
|
|
|
|
function drawSelectionOverlay(ctx, d) {
|
|
if (!d) return;
|
|
const pts = d.points || [];
|
|
const handleColor = d.locked ? "#94a3b8" : "#2962ff";
|
|
pts.forEach(function (p, idx) {
|
|
const x = timeToX(p.time);
|
|
const y = priceToY(p.price);
|
|
if (x == null || y == null) return;
|
|
const large = d.type === "path" && idx === pts.length - 1;
|
|
drawHandle(ctx, x, y, large, handleColor);
|
|
});
|
|
if ((d.type === "trend" || d.type === "channel") && 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) {
|
|
const angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;
|
|
const text = angle.toFixed(2) + "°";
|
|
ctx.font = "11px sans-serif";
|
|
const tw = ctx.measureText(text).width;
|
|
const bx = x2 + 10;
|
|
const by = y2 - 8;
|
|
ctx.fillStyle = "rgba(15, 23, 42, 0.88)";
|
|
ctx.fillRect(bx - 4, by - 12, tw + 8, 18);
|
|
ctx.fillStyle = "#f8fafc";
|
|
ctx.fillText(text, bx, by);
|
|
}
|
|
}
|
|
if (d.locked) {
|
|
const anchor = pts[0];
|
|
if (!anchor) return;
|
|
const ax = timeToX(anchor.time);
|
|
const ay = priceToY(anchor.price);
|
|
if (ax == null || ay == null) return;
|
|
ctx.font = "10px sans-serif";
|
|
ctx.fillStyle = "#94a3b8";
|
|
ctx.fillText("已锁定", ax + 8, ay - 8);
|
|
}
|
|
}
|
|
|
|
function tryEraseAt(x, y) {
|
|
const d = pickDrawingAt(x, y);
|
|
if (!d || d.locked) return false;
|
|
removeDrawing(d.id);
|
|
return true;
|
|
}
|
|
|
|
function onPointerDown(ev) {
|
|
if (activeTool === "cursor" || activeTool === "clear") return;
|
|
if (!chart || !series || !canvasEl) return;
|
|
const loc = clientToLocal(ev);
|
|
if (activeTool === "erase") {
|
|
if (tryEraseAt(loc.x, loc.y)) {
|
|
returnToCursorIfOneShot();
|
|
}
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
const pt = xyToPoint(loc.x, loc.y);
|
|
if (!pt) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
const capturePointer = activeTool === "brush" || isDragTool(activeTool);
|
|
if (capturePointer) {
|
|
try {
|
|
canvasEl.setPointerCapture(ev.pointerId);
|
|
brushPointerId = ev.pointerId;
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (activeTool === "brush") {
|
|
draft = { type: "brush", points: [pt] };
|
|
return;
|
|
}
|
|
if (activeTool === "path") {
|
|
if (!draft || draft.type !== "path") {
|
|
draft = { type: "path", points: [pt] };
|
|
pathPreviewPt = pt;
|
|
} else {
|
|
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) > 4
|
|
) {
|
|
draft.points.push(pt);
|
|
}
|
|
pathPreviewPt = pt;
|
|
}
|
|
scheduleRedraw();
|
|
return;
|
|
}
|
|
if (activeTool === "channel") {
|
|
if (!draft || draft.type !== "channel") {
|
|
draft = { type: "channel", points: [pt] };
|
|
} else if (draft.points.length === 1) {
|
|
draft.points.push(pt);
|
|
} else if (draft.points.length === 2) {
|
|
draft.points.push(pt);
|
|
draft.offset = parallelOffset(draft.points[0], draft.points[1], draft.points[2]);
|
|
commitDrawing(draft);
|
|
}
|
|
scheduleRedraw();
|
|
return;
|
|
}
|
|
if (isDragTool(activeTool)) {
|
|
dragActive = true;
|
|
dragStartPx = { x: loc.x, y: loc.y };
|
|
draft = { type: activeTool, points: [pt, pt] };
|
|
scheduleRedraw();
|
|
return;
|
|
}
|
|
|
|
if (activeTool === "text") {
|
|
const text = window.prompt("输入标注文字", "");
|
|
if (text && String(text).trim()) {
|
|
commitDrawing({ type: "text", points: [pt], text: String(text).trim() });
|
|
}
|
|
return;
|
|
}
|
|
if (pointsNeeded(activeTool) === 1) {
|
|
commitDrawing({ type: activeTool, points: [pt] });
|
|
}
|
|
}
|
|
|
|
function onPointerMove(ev) {
|
|
const loc = clientToLocal(ev);
|
|
if (activeTool === "brush" && draft && draft.type === "brush") {
|
|
const pt = xyToPoint(loc.x, loc.y);
|
|
if (!pt) return;
|
|
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) > 2) {
|
|
draft.points.push(pt);
|
|
scheduleRedraw();
|
|
}
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
if (dragActive && draft && isDragTool(draft.type)) {
|
|
const pt = xyToPoint(loc.x, loc.y);
|
|
if (!pt) return;
|
|
draft.points[1] = pt;
|
|
scheduleRedraw();
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
if (activeTool === "path" && draft && draft.type === "path") {
|
|
const pt = xyToPoint(loc.x, loc.y);
|
|
if (!pt) return;
|
|
pathPreviewPt = pt;
|
|
scheduleRedraw();
|
|
ev.preventDefault();
|
|
}
|
|
}
|
|
|
|
function onPointerUp(ev) {
|
|
const loc = clientToLocal(ev);
|
|
if (brushPointerId != null) {
|
|
try {
|
|
canvasEl.releasePointerCapture(brushPointerId);
|
|
} catch (_) {}
|
|
brushPointerId = null;
|
|
}
|
|
if (dragActive && draft && isDragTool(draft.type)) {
|
|
const dist = dragStartPx
|
|
? Math.hypot(loc.x - dragStartPx.x, loc.y - dragStartPx.y)
|
|
: 0;
|
|
dragActive = false;
|
|
dragStartPx = null;
|
|
const p1 = draft.points[0];
|
|
const p2 = draft.points[1];
|
|
if (
|
|
dist >= MIN_DRAG_PX &&
|
|
p1 &&
|
|
p2 &&
|
|
(p1.price !== p2.price || p1.time !== p2.time)
|
|
) {
|
|
commitDrawing({ type: draft.type, points: [p1, p2] });
|
|
} else {
|
|
cancelDraft();
|
|
}
|
|
return;
|
|
}
|
|
if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) {
|
|
commitDrawing(draft);
|
|
}
|
|
}
|
|
|
|
function onDblClick(ev) {
|
|
if (activeTool === "path" && draft && draft.type === "path") {
|
|
finishPath();
|
|
ev.preventDefault();
|
|
}
|
|
}
|
|
|
|
function onContextMenu(ev) {
|
|
if (activeTool === "path" && draft && draft.type === "path") {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
finishPath();
|
|
}
|
|
}
|
|
|
|
function onMainContextMenu(ev) {
|
|
if (!hostEl) return;
|
|
if (activeTool === "path" && draft && draft.type === "path") return;
|
|
if (draft || dragActive) return;
|
|
const rect = hostEl.getBoundingClientRect();
|
|
const x = ev.clientX - rect.left;
|
|
const y = ev.clientY - rect.top;
|
|
const d = pickDrawingAt(x, y);
|
|
if (!d) {
|
|
hideContextMenu();
|
|
return;
|
|
}
|
|
ev.preventDefault();
|
|
selectDrawing(d.id);
|
|
showContextMenu(ev.clientX, ev.clientY, d);
|
|
}
|
|
|
|
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 "path":
|
|
case "brush":
|
|
if (pts.length >= 2) {
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const x1 = timeToX(pts[i - 1].time);
|
|
const y1 = priceToY(pts[i - 1].price);
|
|
const x2 = timeToX(pts[i].time);
|
|
const y2 = priceToY(pts[i].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 "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;
|
|
}
|
|
case "range":
|
|
case "fib":
|
|
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;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function syncCanvasPointerMode() {
|
|
if (!canvasEl) return;
|
|
const drawing = activeTool !== "cursor";
|
|
canvasEl.classList.toggle("is-drawing", drawing);
|
|
canvasEl.style.pointerEvents = drawing ? "auto" : "none";
|
|
}
|
|
|
|
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;
|
|
dragActive = false;
|
|
dragStartPx = null;
|
|
pathPreviewPt = null;
|
|
draft = null;
|
|
if (toolbarEl) {
|
|
toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) {
|
|
btn.classList.toggle("is-active", btn.getAttribute("data-tool") === tool);
|
|
});
|
|
}
|
|
syncCanvasPointerMode();
|
|
setChartInteraction(activeTool === "cursor");
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function bindToolbar() {
|
|
if (!toolbarEl) return;
|
|
toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
setActiveTool(btn.getAttribute("data-tool") || "cursor");
|
|
});
|
|
});
|
|
}
|
|
|
|
let canvasBound = false;
|
|
|
|
function bindCanvas() {
|
|
if (!canvasEl || canvasBound) return;
|
|
canvasBound = true;
|
|
canvasEl.addEventListener("pointerdown", onPointerDown);
|
|
canvasEl.addEventListener("pointermove", onPointerMove);
|
|
canvasEl.addEventListener("pointerup", onPointerUp);
|
|
canvasEl.addEventListener("pointercancel", onPointerUp);
|
|
canvasEl.addEventListener("dblclick", onDblClick);
|
|
canvasEl.addEventListener("contextmenu", onContextMenu);
|
|
document.addEventListener("keydown", onDrawKeydown);
|
|
}
|
|
|
|
function onDrawKeydown(ev) {
|
|
const page = document.getElementById("page-market");
|
|
if (!page || page.classList.contains("hidden")) return;
|
|
if (isTypingTarget(ev.target)) return;
|
|
if (ev.key === "Escape") {
|
|
if (draft || dragActive) {
|
|
cancelDraft();
|
|
return;
|
|
}
|
|
if (!menuEl || menuEl.classList.contains("hidden")) {
|
|
deselectDrawing();
|
|
} else {
|
|
hideContextMenu();
|
|
}
|
|
return;
|
|
}
|
|
if (ev.key === "Enter" && activeTool === "path" && draft && draft.type === "path") {
|
|
finishPath();
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
if (
|
|
(ev.key === "Delete" || ev.key === "Backspace") &&
|
|
activeTool === "cursor" &&
|
|
selectedId
|
|
) {
|
|
const d = getDrawingById(selectedId);
|
|
if (d && !d.locked) {
|
|
removeSelectedDrawing();
|
|
ev.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
function bindChartClick() {
|
|
if (!chart || typeof chart.subscribeClick !== "function") return;
|
|
if (unsubClick) {
|
|
try {
|
|
unsubClick();
|
|
} catch (_) {}
|
|
unsubClick = null;
|
|
}
|
|
unsubClick = chart.subscribeClick(function (param) {
|
|
if (activeTool !== "cursor" || !param || !param.point) return;
|
|
hideContextMenu();
|
|
const d = pickDrawingAt(param.point.x, param.point.y);
|
|
if (d) selectDrawing(d.id);
|
|
else deselectDrawing();
|
|
});
|
|
}
|
|
|
|
function bindMainEl() {
|
|
if (!mainEl || mainBound) return;
|
|
mainBound = true;
|
|
mainEl.addEventListener("contextmenu", onMainContextMenu);
|
|
}
|
|
|
|
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;
|
|
getCandlesFn = opts.getCandles || null;
|
|
mountCanvasOverlay();
|
|
bindToolbar();
|
|
bindCanvas();
|
|
bindMainEl();
|
|
bindChartClick();
|
|
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;
|
|
dragActive = false;
|
|
dragStartPx = null;
|
|
pathPreviewPt = null;
|
|
draft = null;
|
|
scheduleRedraw();
|
|
}
|
|
|
|
function destroy() {
|
|
if (unsubRange) {
|
|
try {
|
|
unsubRange();
|
|
} catch (_) {}
|
|
unsubRange = null;
|
|
}
|
|
if (unsubClick) {
|
|
try {
|
|
unsubClick();
|
|
} catch (_) {}
|
|
unsubClick = null;
|
|
}
|
|
hideContextMenu();
|
|
setChartInteraction(true);
|
|
}
|
|
|
|
function setTradingDaySplit(enabled) {
|
|
tradingDaySplitEnabled = !!enabled;
|
|
scheduleRedraw();
|
|
}
|
|
|
|
window.HubChartDraw = {
|
|
attach: attach,
|
|
setViewKey: setViewKey,
|
|
setTradingDaySplit: setTradingDaySplit,
|
|
resize: scheduleRedraw,
|
|
redraw: scheduleRedraw,
|
|
destroy: destroy,
|
|
};
|
|
})();
|