/** * 行情区左侧画线工具(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 = '
' + label + "
" + '" + '" + '" + '
' + ''; 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, }; })();