From ef57ba13c5eb7226d7458613dc48b84ed572e656 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 12:50:25 +0800 Subject: [PATCH] fix: drag-to-draw for range and fibonacci like TradingView Co-authored-by: Cursor --- manual_trading_hub/static/chart_draw.js | 186 ++++++++++++++++++------ manual_trading_hub/static/index.html | 12 +- 2 files changed, 147 insertions(+), 51 deletions(-) diff --git a/manual_trading_hub/static/chart_draw.js b/manual_trading_hub/static/chart_draw.js index 999f30c..5d31d7f 100644 --- a/manual_trading_hub/static/chart_draw.js +++ b/manual_trading_hub/static/chart_draw.js @@ -5,6 +5,8 @@ const STORAGE_PREFIX = "hubMarketDraw:"; const HIT_PX = 8; const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]; + const DRAG_TOOLS = new Set(["trend", "rect", "range", "fib"]); + const MIN_DRAG_PX = 6; const TOOL_LABELS = { cursor: "光标", @@ -37,6 +39,8 @@ let unsubRange = null; let getCandlesFn = null; let brushPointerId = null; + let dragActive = false; + let dragStartPx = null; function uid() { return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); @@ -209,6 +213,26 @@ 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 isDragTool(tool) { + return DRAG_TOOLS.has(tool); + } + + function cancelDraft() { + draft = null; + dragActive = false; + dragStartPx = null; + scheduleRedraw(); + } + function drawLine(ctx, x1, y1, x2, y2, selected) { if (x1 == null || y1 == null || x2 == null || y2 == null) return; ctx.beginPath(); @@ -272,44 +296,70 @@ 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 yTop = priceToY(top); + const yBot = priceToY(bot); + if (x1 == null || x2 == null || yTop == null || yBot == 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; + const color = strokeStyle(selected); + + drawLine(ctx, left, yTop, left, yBot, selected); + drawLine(ctx, drawRight, yTop, drawRight, yBot, selected); + + let lastLabelY = -9999; 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.strokeStyle = color; ctx.lineWidth = 1; - ctx.setLineDash(lv === 0 || lv === 1 ? [] : [4, 4]); + ctx.setLineDash(lv === 0 || lv === 1 ? [] : [5, 4]); ctx.moveTo(left, y); - ctx.lineTo(right, y); + ctx.lineTo(drawRight, y); ctx.stroke(); + if (Math.abs(y - lastLabelY) < 13) return; + lastLabelY = y; ctx.fillStyle = "#9aa4b8"; ctx.font = "10px sans-serif"; - ctx.fillText((lv * 100).toFixed(1) + "%", left + 4, y - 3); + ctx.fillText((lv * 100).toFixed(1) + "% " + formatPrice(price), drawRight + 6, y + 3); }); ctx.setLineDash([]); } 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); - const x = timeToX(p1.time) != null ? timeToX(p1.time) : timeToX(p2.time); - if (y1 == null || y2 == null || x == null) return; + 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); - ctx.strokeStyle = strokeStyle(selected); - ctx.fillStyle = selected ? "rgba(245,158,11,0.12)" : "rgba(96,165,250,0.1)"; + const boxW = Math.max(right - left, 10); + const boxH = Math.max(bot - top, 6); + const color = strokeStyle(selected); + + ctx.fillStyle = selected ? "rgba(245,158,11,0.14)" : "rgba(96,165,250,0.12)"; + ctx.strokeStyle = color; 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.setLineDash([]); + ctx.fillRect(left, top, boxW, boxH); + ctx.strokeRect(left, top, boxW, boxH); + + const pct = p1.price ? ((p2.price - p1.price) / p1.price) * 100 : 0; + const diff = p2.price - p1.price; + const midX = left + boxW / 2; + const midY = top + boxH / 2; ctx.fillStyle = "#e2e8f0"; - ctx.font = "10px sans-serif"; - ctx.fillText(pct + "%", x + 18, (top + bot) / 2); + ctx.font = "11px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(pct.toFixed(2) + "%", midX, midY - 5); + ctx.fillText(formatPrice(diff), midX, midY + 11); + ctx.textAlign = "left"; } function drawText(ctx, p, text, selected) { @@ -528,39 +578,42 @@ 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) { + 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; } - - 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); + if (isDragTool(activeTool)) { + dragActive = true; + dragStartPx = { x: loc.x, y: loc.y }; + draft = { type: activeTool, points: [pt, pt] }; + scheduleRedraw(); return; } - if (draft.points.length >= pointsNeeded(activeTool)) { - commitDrawing(draft); + + 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] }); } - scheduleRedraw(); } function onPointerMove(ev) { + const loc = clientToLocal(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]; @@ -575,26 +628,43 @@ 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); - } + if (dragActive && draft && isDragTool(draft.type)) { + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; + draft.points[1] = 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); } @@ -666,6 +736,22 @@ 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; } @@ -702,6 +788,8 @@ return; } activeTool = tool; + dragActive = false; + dragStartPx = null; draft = null; if (toolbarEl) { toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) { @@ -732,6 +820,12 @@ canvasEl.addEventListener("pointerup", onPointerUp); canvasEl.addEventListener("pointercancel", onPointerUp); canvasEl.addEventListener("dblclick", onDblClick); + document.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") return; + const page = document.getElementById("page-market"); + if (!page || page.classList.contains("hidden")) return; + if (draft || dragActive) cancelDraft(); + }); } function attach(opts) { @@ -763,6 +857,8 @@ viewKey = key || ""; drawings = loadDrawings(); selectedId = null; + dragActive = false; + dragStartPx = null; draft = null; scheduleRedraw(); } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 799a620..1759f63 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -188,14 +188,14 @@ - - -