/** * 行情区左侧画线工具(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; let getCandlesFn = null; let brushPointerId = 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 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 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 tryEraseAt(x, y) { for (let i = drawings.length - 1; i >= 0; i--) { if (hitTestDrawing(drawings[i], x, y)) { drawings.splice(i, 1); selectedId = null; saveDrawings(); scheduleRedraw(); return true; } } return false; } function onPointerDown(ev) { if (activeTool === "cursor" || activeTool === "clear") return; if (!chart || !series || !canvasEl) return; const loc = clientToLocal(ev); if (activeTool === "erase") { tryEraseAt(loc.x, loc.y); ev.preventDefault(); return; } const pt = xyToPoint(loc.x, loc.y); if (!pt) return; ev.preventDefault(); ev.stopPropagation(); 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] }; } 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 (activeTool === "brush" && draft && draft.type === "brush") { const loc = clientToLocal(ev); 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 (!draft) return; const loc = clientToLocal(ev); const pt = xyToPoint(loc.x, loc.y); if (!pt) 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 (brushPointerId != null) { try { canvasEl.releasePointerCapture(brushPointerId); } catch (_) {} brushPointerId = null; } 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 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; 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); } 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(); 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, }; })();