diff --git a/manual_trading_hub/static/chart_draw.js b/manual_trading_hub/static/chart_draw.js index 5d31d7f..bfdc387 100644 --- a/manual_trading_hub/static/chart_draw.js +++ b/manual_trading_hub/static/chart_draw.js @@ -4,8 +4,30 @@ (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 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 = { @@ -19,7 +41,7 @@ text: "文字", fib: "斐波那契", trend: "趋势线", - path: "折线箭头", + path: "折线", erase: "删除选中", clear: "清除全部", }; @@ -41,6 +63,7 @@ let brushPointerId = null; let dragActive = false; let dragStartPx = null; + let pathPreviewPt = null; function uid() { return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); @@ -222,6 +245,73 @@ 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); } @@ -230,9 +320,16 @@ 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(); @@ -296,25 +393,54 @@ 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) return; + 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; - const color = strokeStyle(selected); - drawLine(ctx, left, yTop, left, yBot, selected); - drawLine(ctx, drawRight, yTop, drawRight, yBot, selected); + 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 = bot + (top - bot) * (1 - 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 = color; + ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.setLineDash(lv === 0 || lv === 1 ? [] : [5, 4]); ctx.moveTo(left, y); @@ -322,11 +448,16 @@ ctx.stroke(); if (Math.abs(y - lastLabelY) < 13) return; lastLabelY = y; - ctx.fillStyle = "#9aa4b8"; + const lvLabel = lv === 1 || lv === 0 ? String(lv) : String(lv); + ctx.fillStyle = lineColor; ctx.font = "10px sans-serif"; - ctx.fillText((lv * 100).toFixed(1) + "% " + formatPrice(price), drawRight + 6, y + 3); + 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) { @@ -341,25 +472,41 @@ const bot = Math.max(y1, y2); const boxW = Math.max(right - left, 10); const boxH = Math.max(bot - top, 6); - const color = strokeStyle(selected); + const borderColor = selected ? "#f59e0b" : "#1e293b"; - ctx.fillStyle = selected ? "rgba(245,158,11,0.14)" : "rgba(96,165,250,0.12)"; - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; + 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 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 = "11px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(pct.toFixed(2) + "%", midX, midY - 5); - ctx.fillText(formatPrice(diff), midX, midY + 11); - ctx.textAlign = "left"; + 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) { @@ -414,28 +561,53 @@ 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; + 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.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(); + 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) { + function renderDrawing(ctx, d, w, h, selected, previewPt) { const pts = d.points || []; if (!pts.length) return; switch (d.type) { @@ -497,7 +669,7 @@ if (pts.length >= 2) drawFib(ctx, pts[0], pts[1], w, selected); break; case "path": - drawPath(ctx, pts, selected); + drawPath(ctx, pts, selected, previewPt || null); break; default: break; @@ -515,7 +687,10 @@ drawings.forEach(function (d) { renderDrawing(ctx, d, w, h, d.id === selectedId); }); - if (draft) renderDrawing(ctx, draft, w, h, true); + if (draft) { + const preview = draft.type === "path" ? pathPreviewPt : null; + renderDrawing(ctx, draft, w, h, true, preview); + } } function commitDrawing(d) { @@ -523,8 +698,18 @@ d.id = uid(); drawings.push(d); 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) { @@ -552,7 +737,9 @@ if (!chart || !series || !canvasEl) return; const loc = clientToLocal(ev); if (activeTool === "erase") { - tryEraseAt(loc.x, loc.y); + if (tryEraseAt(loc.x, loc.y)) { + returnToCursorIfOneShot(); + } ev.preventDefault(); return; } @@ -560,10 +747,13 @@ if (!pt) return; ev.preventDefault(); ev.stopPropagation(); - try { - canvasEl.setPointerCapture(ev.pointerId); - brushPointerId = ev.pointerId; - } catch (_) {} + 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] }; @@ -572,8 +762,23 @@ if (activeTool === "path") { if (!draft || draft.type !== "path") { draft = { type: "path", points: [pt] }; + pathPreviewPt = pt; } else { - draft.points.push(pt); + 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; @@ -634,6 +839,14 @@ 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(); } } @@ -671,12 +884,18 @@ } function onDblClick(ev) { - if (activeTool === "path" && draft && draft.type === "path" && draft.points.length > 1) { - commitDrawing(draft); + if (activeTool === "path" && draft && draft.type === "path") { + finishPath(); ev.preventDefault(); } } + function onContextMenu(ev) { + if (activeTool !== "path" || !draft || draft.type !== "path") return; + ev.preventDefault(); + finishPath(); + } + function distSeg(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; @@ -717,18 +936,18 @@ } } return false; - case "rect": + case "path": + case "brush": 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; + 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": { @@ -790,6 +1009,7 @@ activeTool = tool; dragActive = false; dragStartPx = null; + pathPreviewPt = null; draft = null; if (toolbarEl) { toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) { @@ -820,11 +1040,18 @@ canvasEl.addEventListener("pointerup", onPointerUp); canvasEl.addEventListener("pointercancel", onPointerUp); canvasEl.addEventListener("dblclick", onDblClick); + canvasEl.addEventListener("contextmenu", onContextMenu); 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(); + if (ev.key === "Escape" && (draft || dragActive)) { + cancelDraft(); + return; + } + if (ev.key === "Enter" && activeTool === "path" && draft && draft.type === "path") { + finishPath(); + ev.preventDefault(); + } }); } @@ -859,6 +1086,7 @@ selectedId = null; dragActive = false; dragStartPx = null; + pathPreviewPt = null; draft = null; scheduleRedraw(); } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 1759f63..50e14bb 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - +
@@ -198,7 +198,7 @@ -